Compare commits

...
Author SHA1 Message Date
Expo CI 86b85e7d8d v24.9.0 2026-10-02 17:32:47 +00:00
Gabe DebesandClaude 03a7d3a6ee [eas-cli] Only forward --non-interactive from update:rollback in non-interactive mode (#4504)
* [eas-cli] only forward --non-interactive from update:rollback when it was passed

* [eas-cli] fix the changelog entry

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

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

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

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

* [eas-cli] cover the active rollout paths

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

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

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

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

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

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

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

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

* [eas-cli] tweak changelog

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

---------

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

Just a small improvement.

## How

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

## Test Plan

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

* [build-tools] link token fix changelog

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

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

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

* [build-tools] extract upload API key variable

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [build-tools] Remove duplicated preview screenshot tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [build-tools] Format the preview screenshot changes

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

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

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

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

---------

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

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

# How

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

# Test Plan

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

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

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

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

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

* [build-tools] Add changelog entry

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

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

Review follow-ups for `eas/start_agent_device_session`:

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

# How

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

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

# Test Plan

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

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

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

* [build-tools] Add changelog entry

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

---------

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

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

# Why

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

# How

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

# Test Plan

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

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

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

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

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

* [ci] Update the ffmpeg install comment

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* [build-tools] Stop automation alongside recording cleanup

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

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

* [build-tools] Extract session host shutdown operations

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* [eas-cli] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 00:21:04 +02:00
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
Expo CI 195807643f v24.4.2 2026-09-15 05:03:15 +00:00
Brent Vatne 21256e6d55 [eas-cli] Support Xcode 27 Device Hub when running iOS simulator builds (#4405) 2026-09-14 22:00:43 -07:00
Expo CI 705fa875d3 bump STAGING and PRODUCTION to 24.4.1 in cli-versions.json 2026-09-15 04:14:14 +00:00
Expo CI 498384357e update CHANGELOG.md for v24.4.1 2026-09-15 04:10:19 +00:00
Expo CI 53938e4cf8 v24.4.1 2026-09-15 04:07:48 +00:00
Brent Vatne e18ffcd48e [eas-cli] Bump @expo/apple-utils to 2.2.1 (#4404) 2026-09-14 21:04:22 -07:00
Expo CI df12f86da9 bump STAGING and PRODUCTION to 24.4.0 in cli-versions.json 2026-09-14 21:17:19 +00:00
Expo CI 3dba7f2bf7 update CHANGELOG.md for v24.4.0 2026-09-14 21:13:37 +00:00
Expo CI e7efe14336 v24.4.0 2026-09-14 21:11:01 +00:00
Brent Vatne 78f2103bcb [build-tools] inject a guard into simulator processes that refuses connections bypassing the local egress proxy (#4393) 2026-09-14 14:08:02 -07:00
Brent Vatne 565b0ec425 [build-tools] set proxy environment variables in the Simulator for local egress (#4390) 2026-09-14 13:56:04 -07:00
Ramon Claudio 253d72bb4d [eas-cli] Bump Node to 20.18.3 (#4279)
* Update package.json to use new node version

* Update test workflow to use new node version

* Update important notes section in CLAUDE.md to use new node version

* Add changelog
2026-09-14 15:20:14 -04:00
Stanisław Chmiela 35522e9b28 [build-tools] Skip source refresh for jobs with no sources (#4389)
# Why

`workflow-orchestration` tests rightly use `projectArchive.type: NONE`. NONE sources don't need refresh so we can just handle them inline.

# How

If project archive type is NONE, just return, there's nothing to do, refresh, or limit based on job type.

# Test Plan

CI should fix. E2E tests should pass.
2026-09-14 11:34:15 +02:00
Expo CI 4aa9522825 bump STAGING and PRODUCTION to 24.3.0 in cli-versions.json 2026-09-11 12:37:23 +00:00
Expo CI a92b0b0ad5 update CHANGELOG.md for v24.3.0 2026-09-11 12:34:24 +00:00
Expo CI 66cdccb0e7 v24.3.0 2026-09-11 12:31:21 +00:00
Stanisław Chmiela 2eefa44f8f [eas-build-job] Add sandbox daemon protocol (#4346)
## Why

"sandboxd" from `build-tools` needs to connect to the MCP server in `universe`. They need to be able to talk to each other. I'm thinking it might be nice if they shared an "API protocol"?

## How

Added Zod schema for input and output of to-be-supported messages to `eas-build-job`.

## Test plan

CI should pass. I think I tested this locally too.
2026-09-11 14:23:53 +02:00
Stanisław Chmiela 55302dd830 [build-tools] Add sandbox MCP connection step (#4333)
# Why

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

# How

Added a simple "sandbox daemon" that connects to MCP and an `eas/start_sandbox` EAS function.

This sets up the basic structure, planning to take care of protocol, handling messages and executing commands will come in future PRs.

# Test Plan

Tested with https://github.com/expo/universe/pull/30766 and https://github.com/expo/universe/pull/30763.

```
<-- GET /sandbox/connect
New sandbox daemon - sandboxId[01a0692f-b464-77c8-8ab0-e2143eec9872] ipAddress[::1]
--> GET /sandbox/connect 101 23ms
[wrangler:info] GET /sandbox/connect 101 Switching Protocols (50ms)
```
2026-09-11 14:02:24 +02:00
Expo CI 924eb87157 bump STAGING and PRODUCTION to 24.2.0 in cli-versions.json 2026-09-11 11:01:44 +00:00
Expo CI f546bf3352 update CHANGELOG.md for v24.2.0 2026-09-11 10:55:55 +00:00
Expo CI 97553334d4 v24.2.0 2026-09-11 10:52:46 +00:00
Stanisław Chmiela 31d12b9888 [eas-build-job] Remove repository URLs from Git job inputs (#4360)
* [eas-build-job] Allow jobs to omit the Git repository URL

* [build-tools] Separate resolved archive sources from job inputs

* [eas-build-job] Remove repository URLs from Git job inputs

* [eas-build-job] Remove the legacy repository URL schema field

* [eas-build-job] Move changelog entry to unreleased section

* [eas-build-job] Correct unreleased changelog placement
2026-09-11 12:19:58 +02:00
Ash Wu dc2658f63b [eas-cli] add eas workflow:insights:maestro command (#4370)
Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Ash Wu b8b9de78fc [eas-cli] add eas workflow:insights command (#4367)
* [eas-cli] add eas workflow:insights command

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] address review feedback on workflow:insights

- Show workflow file names in the workflows table, which is the value
  --workflow accepts, and add fileName to the JSON output
- Omit buckets with no runs from the runs-over-time table; the JSON output
  keeps every bucket
- Replace the per-command --project-id context resolvers with a protected
  EasCommand helper shared with the observe commands

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] send a bucket-aligned time range from workflow:insights

The server widens only the runs-over-time query to whole buckets and leaves the
overview and per-workflow queries on the exact bounds, so an unaligned window
made those sections count different runs and the bucket totals could exceed the
overview's total. Widen the window client-side instead, the way the dashboard
does, so the server's own widening is a no-op and every section covers the same
period.

Widening lengthens the window, which can push it past the threshold of the
bucket size it was widened by, so the bucket size is chosen by checking the
widened window rather than the requested one. Without that, --days 4 would ask
for hourly buckets over a 97-hour window and the server would reject it.

- Reject a range that does not run forwards in resolveTimeRange, next to the
  existing flag checks; widening would otherwise turn a backwards range into a
  valid one-bucket query
- Enable JSON output before resolving the time range and the project context,
  both of which can write prose to stdout
- Show a metric's change in parentheses, matching how the observe formatters
  pair a statistic with its secondary figure

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-09-11 17:57:30 +08:00
Stanisław Chmiela 8ff599b1f2 [build-tools] Require PATH sources for local builds (#4362)
* [build-tools] Require PATH sources for local builds

* [build-tools] Document local source validation

* [build-tools] Report invalid local sources as user errors

* [build-tools] Track invalid local sources as system errors
2026-09-11 11:03:45 +02:00
Expo CI e6f02af973 bump STAGING and PRODUCTION to 24.1.2 in cli-versions.json 2026-09-11 00:59:22 +00:00
Expo CI a163b5a76d update CHANGELOG.md for v24.1.2 2026-09-11 00:57:25 +00:00
Expo CI cc37c1c360 v24.1.2 2026-09-11 00:55:13 +00:00
Brent Vatne e78771e0f4 [eas-cli] drop the duplicate non-interactive egress hint and the disconnect warning on stop (#4384) 2026-09-10 17:52:34 -07:00
Brent Vatne 288bc44dfc [eas-cli] shorten the local egress banner and drop the run-it-yourself instruction in interactive mode (#4383) 2026-09-10 17:42:22 -07:00
Expo CI d3db3926e3 bump STAGING and PRODUCTION to 24.1.1 in cli-versions.json 2026-09-11 00:15:23 +00:00
Expo CI 5b87ba0adb update CHANGELOG.md for v24.1.1 2026-09-11 00:13:05 +00:00
Expo CI 0b2cea7bb7 v24.1.1 2026-09-11 00:10:40 +00:00
Brent Vatne eab4fc3cd5 [eas-cli] describe the inline egress client instead of asking to start one (#4382) 2026-09-10 17:07:16 -07:00
Brent Vatne e2e8cd245c [eas-cli] improve EasCommand test reliability (#4381) 2026-09-10 17:05:16 -07:00
Expo CI 80d46f0783 bump STAGING and PRODUCTION to 24.1.0 in cli-versions.json 2026-09-10 23:27:17 +00:00
Expo CI 3b6b63a273 update CHANGELOG.md for v24.1.0 2026-09-10 23:24:59 +00:00
Expo CI 8373ae353c v24.1.0 2026-09-10 23:22:26 +00:00
Brent Vatne 994794dbad [eas-cli] add --egress-allow to reach local dev servers through local egress (#4369)
* [eas-cli] add --egress-allow so local egress sessions can reach dev servers on this machine

Adds repeatable --egress-allow host:port, requiring --egress local. Entries
are normalized and deduplicated; wildcards, ranges, and malformed values are
rejected before session creation. Exceptions match the exact host and port;
unlisted destinations keep the default policy. Exceptions persist in the
session configuration and are shown when starting or reconnecting, across all
iOS controllers.

Squashes the earlier --egress-allow commits and base-branch sync merges so the
branch sits directly on the rebased base.

* [eas-cli] forward loopback --egress-allow ports from the simulator host like adb reverse

iOS never sends 127.0.0.1 requests to the system proxy and Expo CLI rewrites
localhost to 127.0.0.1 in manifests, so --egress-allow localhost:<port> alone
could not serve a dev server to Expo Go. For each localhost or 127.0.0.1 entry
the egress client now opens a chisel reverse remote so 127.0.0.1:<port> on the
device host reaches the same port on this machine. Privileged ports and the
proxy port stay name-only. The start output explains the forwarding, and
rejected remotes are surfaced instead of looping silently.
2026-09-10 14:12:01 -07:00
Brent Vatne d103da3bc5 [eas-cli][build-tools] local egress for iOS simulator sessions (--egress local) (#4364)
* [build-tools] add start_local_egress step for local egress simulator sessions

* [eas-cli] add --egress local to simulator:start and add simulator:egress

* [eas-cli] update CHANGELOG

* [build-tools] drop the pf UDP block from local egress and report direct simulator connections

* [eas-cli] harden the local egress proxy and narrow the contract wording

* [build-tools][eas-cli] report the egress token separately from the fixed username

* [build-tools][eas-cli] harden local egress startup and proxy resource limits

* [build-tools][eas-cli] support local egress across iOS simulator controllers

* [build-tools] allow local egress reverse remotes on unprivileged loopback ports

iOS never sends loopback-literal requests to the system proxy, and Expo CLI
rewrites localhost to 127.0.0.1 in manifests, so a dev server allowed as
localhost:<port> could not serve bundles through the proxy. Let the EAS CLI
open one chisel reverse remote per loopback allow entry (like adb reverse)
by permitting reverse remotes on any unprivileged loopback port, not only
the proxy port. Other interfaces and privileged ports stay denied.

* [eas-cli] stop logging completed proxied responses as failed in the local egress client

Releasing a proxy operation aborts its shared signal after the response has
been sent, which errors the already-completed upstream request and logged
"failed: The operation was aborted" for every successful plain-HTTP request in
debug output. Track completion (response end or upgrade handoff) and treat the
late abort as cleanup. Truncated upstream responses still log as failed.
2026-09-10 14:12:01 -07:00
Stanisław Chmiela ee3c62683b [build-tools] Require project source refresh for remote builds (#4359)
# Why

I want to start skipping authenticated repository URL in the job payload. We never expect to use it — we'd rather use the just-loaded one from www.

# How

Change fallback to a `SystemError`.

# Test Plan

CI should pass.
2026-09-10 22:40:41 +02:00
Gabe Debes 8ac9d5f101 [build-tools] gate serve-sim with the session token (#4306)
* [eas-cli] gate device-run-session serve-sim with the session token

* [eas-cli] describe the state the missing-token check actually catches

* [eas-cli] match the missing-token test to the message it asserts
2026-09-09 17:40:01 -07:00
Expo CI 1eaa799b9f bump STAGING and PRODUCTION to 24.0.0 in cli-versions.json 2026-09-09 20:13:34 +00:00
Expo CI bb0f2dfb21 update CHANGELOG.md for v24.0.0 2026-09-09 20:11:16 +00:00
Expo CI 5409c03d58 v24.0.0 2026-09-09 20:07:38 +00:00
Gabe Debes a941930ea7 [eas-cli] adds serve-sim session token in simulator preview links (#4312)
* [eas-cli] carry the serve-sim session token in simulator preview links

* [eas-cli] sanitize the preview config in json output
2026-09-09 12:52:08 -07:00
Szymon Świerk 74572101ac [eas-cli] Make ASC API key issuer identifier optional in types (#4248)
* [eas-cli] Make ASC API key issuer identifier optional in types

* [eas-cli] Bump @expo/apple-utils to 2.2.0

* [eas-cli] Pass optional issuer identifier through to TestFlight app lookup
2026-09-09 21:22:32 +02:00
Stanisław Chmiela 7b050cae3f [worker] Add MCP server URL to build context (#4335)
# Why

Worker has config for API server URL. I now need MCP server URL too.

# How

Added `mcpServerUrl` in `config` and in contexts.

# Test Plan

CI should pass.
2026-09-09 16:56:49 +02:00
Douglas LowderandClaude Opus 4.8 d193860668 [eas-cli] Add observe:event command to fetch a single event by ID (#4252)
* [eas-cli] Add observe:event command to fetch a single event by ID

Add `eas observe:event <id>` to display one Observe event (either metric or userEvent) by its ID. Also, ensure event IDs are propagated to the results of `eas observe:session <sessionId>` so that they appear in JSON output.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-09 06:07:57 -07:00
Krystof WoldrichandCodex 1da0be8529 [build-tools] Use bun x to start Argent remote sessions (#4366)
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-08 15:22:47 +02:00
Douglas LowderandClaude Opus 4.8 08b4346620 [eas-cli] Add observe:errors command (#4339)
* [eas-cli] Add observe:errors command

Add `eas observe:errors` to display error and exception issue groups grouped by
fingerprint, using the new observe.errors.groups GraphQL namespace. Exceptions
are excluded from observe:events (userEvents.list) and surfaced here instead.
Supports --platform, --severity (fatal|error), time-range, --app-version,
--build-number, --update-id, and --environment filters, with table and --json
output.

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

* [eas-cli] Add --fingerprint drill-down to observe:errors

Add `eas observe:errors --fingerprint <fingerprint>` to list an error group's
individual occurrences with stack traces, instead of only the grouped summary.
Occurrences are fetched via observe.errors.occurrences (newest first) with
--limit and --after paging, and rendered as per-occurrence detail (fields,
stack trace, body, properties) or as --json. The grouped table now shows a
Fingerprint column and a hint pointing to the flag.

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

* Respond to review comments

* Regenerate GraphQL from server

* Fix errors from nullability change in issuerIdentifier

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-07 20:29:56 -07:00
4d6d9b1fc4 [build-tools] Allow configuring Android emulator display (#4349)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex (GPT-6) <[email protected]>
2026-09-07 15:39:56 +02:00
Stanisław Chmiela 59f9add53c [build-tools] Add custom log phase helper (#4334)
# Why

I want a single EAS function to appear as running as two separate phases.

# How

Added `withLogPhaseAsync` which is going to create a logger child with necessary fields overwritten so a new log phase appears.

# Test Plan

CI should pass. Verified locally.

<img width="1343" height="712" alt="Zrzut ekranu 2026-09-4 o 12 17 25" src="https://github.com/user-attachments/assets/f4733c83-3119-42d6-b7dc-fc46aedaf434" />
2026-09-07 15:11:57 +02:00
1946562247 [build-tools] Update Appium command list (#4356)
Co-authored-by: Expo Bot <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-09-07 10:41:29 +00:00
SJ Kim 24a65ad80f [eas-cli] Support protected update channels (#4319)
* [eas-cli] Support protected update channels

* [eas-cli] Add protected channels changelog entry

* [eas-cli] Handle missing protected channels

* [eas-cli] Address channel protection review feedback (ENG-25975)
2026-09-04 14:03:53 -04:00
Hirbod 37ea60c20c [eas-cli] remove hidden AI chat command (#4348) 2026-09-04 17:03:29 +02:00
Douglas LowderandClaude Opus 4.8 99cbcae249 [eas-cli] Align Observe code with new server schema (#4338)
* [eas-cli] Align observe commands with the new Observe GraphQL schema

The Observe GraphQL schema was reorganized into scoped namespaces (universe
#30606). Migrate the observe queries onto the new fields — metrics.list,
userEvents.list/names, navigation.routes, session(id){metrics logs} — and adopt
the renamed fields in --json output (metricName->name, metricValue->value,
eventName->name). observe:events now lists user-defined events only.
observe:session timelines now include errors via session.logs. appVersions
(observe:versions, observe:metrics-summary) is unchanged. Regenerate GraphQL and
update tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 06:29:01 -07:00
Szymon Świerk a426042784 [build-tools] Support individual ASC API keys in upload_to_asc (#4247)
* [build-tools] Support individual ASC API keys in upload_to_asc

* [build-tools] In upoload_to_asc function, set jwt subject with a dedicated function instead of setting the claim manually
2026-09-03 16:34:50 +02:00
1cb06a6cb2 [build-tools] Install FFmpeg before Android device hub (#4332)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 15:08:04 +02:00
Krystof WoldrichandCodex 5236871b0b fix(ci): changelog for PRs from forks (#4331)
Co-authored-by: Codex <[email protected]>
2026-09-03 12:49:30 +02:00
ceb20c9dd6 [build-tools] Use lower 980px Android preview resolution (#4326)
Co-authored-by: Krystof Woldrich <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-09-03 10:43:09 +02:00
Krystof WoldrichandCodex 83fa42c179 [eas-cli] Avoid GraphQL argument type collisions (#4328)
Co-authored-by: Codex <[email protected]>
2026-09-02 16:50:52 +02:00
Stanisław Chmiela 7727b9c347 [ci] Gate worker CLI versions on npm availability (#4313)
We currently:
- publish to NPM without awaiting the release being ready and public
- immediately change `cli-versions.json`
- this makes builds immediately try to use a new `eas-cli` version despite it NOT being public yet, which causes problems, see [Datadog](https://app.datadoghq.com/logs?query=%22No%20matching%20version%20found%20for%20eas-cli%22&agg_m=count&agg_m_source=base&agg_t=count&cols=host%2Cservice&fromUser=true&messageDisplay=inline&refresh_mode=paused&storage=hot&stream_sort=desc&viz=stream&from_ts=1788174847023&to_ts=1788178586558&live=false).

This pull request makes it so we:
- publish to NPM, make a GitHub release
- update changelog and push to main
- await NPM readiness for 10 minutes (last release took 6)
- only once it's ready, update `cli-versions.json`.
2026-09-02 09:02:12 +00:00
Stanisław Chmiela b015c0b226 [worker] Keep legacy cache failures non-fatal (#4295)
Add Sentry logging to legacy build cache.
2026-09-02 09:29:59 +02:00
Kadi Kraman 882687818f [eas-cli] add --build-number to observe:metrics and observe:events (#4278)
* Add --build-number to observe:metrics and observe:events

* Add changelog
2026-09-01 20:46:41 +01:00
Kadi Kraman 37f5163872 [eas-cli] add apple platform value to observe commands (#4276)
* Regenerate GraphQL schema

* Add apple platform target model

* Query observe data by platform target

* Wire --platform apple through the observe commands

* Update observe tests for platform targets

* Add changelog

* Format

* Regenerate gql

* Sync gql
2026-09-01 20:08:39 +01:00
Stanisław Chmiela df2c81d131 [eas-cli] Update simulator device GraphQL inputs (#4327) 2026-09-01 19:37:08 +01:00
Kadi Kraman 236398a823 [eas-cli] add environment filter to observe commands (#4275)
* Add shared --environment flag and thread it through the observe query layer

* Add --environment to observe:metrics-summary

* Add --environment to observe:metrics

* Add --environment to observe:routes

* Add --environment to observe:events

* Add --environment to observe:versions

* Add --environment to the observe:session picker

* Add changelog

* Add links to changelog
2026-09-01 13:49:33 +01:00
Jon Samp eebe02eef2 [eas-cli] Add project:status command (#4006)
* [eas-cli] Add project:status command

Adds `eas project:status` (alias `eas status`), a snapshot of the project
overview: recent production builds, development builds, workflow runs,
submissions, and updates. Mirrors the website project dashboard and
supports --json and --non-interactive for agent consumption.

* [eas-cli] Fix changelog PR link

* [eas-cli] Address project status review feedback

* [eas-cli] Improve project status for agents

* [eas-cli] Clarify project status pagination lookahead

* [eas-cli] Adapt project status to current GraphQL fields

* [eas-cli] Remove dead README link to simulator/start.ts

* [eas-cli] Normalize unknown workflow run statuses in project status
2026-08-31 22:43:59 -05:00
Expo CI 4b1e5a76e2 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 20:28:03 +00:00
Expo CI 74292d2ace v23.2.0 2026-08-31 20:25:25 +00:00
Szymon Dziedzic b0b7cad316 [eas-cli] Warn about Android simulator parity (#4320)
* [eas-cli] warn about Android simulator parity

* [eas-cli] add Android simulator warning changelog
2026-08-31 22:22:53 +02:00
expo-tuft[bot]andtchayen 926887d14e [eas-cli] Add --tag to simulator session commands (#4318)
Lets projects with several app variants group and filter their sessions.

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-31 22:40:52 +03:00
Gabe DebesandTomasz Mazur 2e6bb2d276 [eas-cli] Add --ssh to workflow:run (#4033)
* [eas-cli] add --ssh to workflow:run

* [eas-cli] Restore WorkflowRunSshInput types dropped by staging regen

Staging schema does not include the --ssh input yet (universe #29128),
but the CLI stack already depends on it. Keep the types until that ships.

* Regenerate GraphQL

* Dedupe changelog

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
Gabe DebesandTomasz Mazur 30656509ac [eas-cli] Add workflow:ssh command (#4032)
* [eas-cli] add workflow:ssh command

* Poll for an open SSH session before sleeping.

Avoids a needless wait when the first connect-info fetch is already ready.

* Update SSH wait test for poll-before-sleep.

The wait loop now polls first, so keep one pending poll before ready.

* Cover WorkflowJobSshQuery error and turtleBuild session paths.

* [eas-cli] Regen GraphQL types from staging (TurtleSshSession)

* [eas-cli] address workflow:ssh review feedback

* [eas-cli] drop the GET_BUILD check from sshRequested

* [eas-cli] accept a job run id in workflow:ssh

* Use URL to parse the host

* Regenerate GraphQL

* Move changelog entry

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
66e3f1ad7b [build-tools] Use expo-device-hub for Android web previews (#4304)
Co-authored-by: szdziedzic <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-08-31 15:49:49 +02:00
Tomasz Mazur 9aadf2d467 [eas-cli] Stream workflow run logs in real time (#4228)
* [eas-cli] Add centrifuge and ws to deps

* [eas-cli] Stream workflow job logs while waiting for a run

* [eas-cli] Add changelog entry for realtime workflow logs

* [eas-cli] Make msg optional and fix flickering

* [eas-cli] Fetch job logs without casing on job type

* [eas-cli] Ingest realtime incrementally instead of rebuilding full logs on every message (#4293)
2026-08-31 13:38:38 +00:00
Stanisław Chmiela ba80e57753 [eas-cli] Update GraphQL schema for web preview sessions (#4315)
Updated GraphQL and adjusted code.
2026-08-31 15:20:33 +02:00
Expo CI 50c76f41e3 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 11:27:55 +00:00
Expo CI 0658d58efd v23.1.0 2026-08-31 11:24:34 +00:00
Phil Pluckthun 76a700c45c Remove verify step (#4310) 2026-08-29 18:07:45 +01:00
Szymon Dziedzic 5ded193ccf [eas-cli] Fix v23.0.0 changelog (#4309) 2026-08-29 11:24:10 +02:00
Brent Vatne 93420d1fa0 [eas-cli] Add prebuild fallback regression tests (#4285)
* [eas-cli] Add prebuild fallback regression tests

* Update changelog for prebuild fallback tests
2026-08-28 15:45:29 -07:00
Brent Vatne add787f625 [local-build-plugin] Replace bunyan with @expo/bunyan (#4284)
* [local-build-plugin] Replace bunyan with @expo/bunyan

* Update changelog for @expo/bunyan
2026-08-28 15:45:28 -07:00
Brent Vatne ec2c2aca5e [steps] Replace lodash.get with a local helper (#4283)
* [steps] Replace lodash.get with a local helper

* Update changelog for lodash.get removal
2026-08-28 15:45:28 -07:00
Brent Vatne 1e86c48f48 [eas-cli][steps] Bump uuid to v11 (#4282)
* [eas-cli][steps] Bump uuid to v11

* Update changelog for uuid v11
2026-08-28 15:45:27 -07:00
Brent Vatne 545c2dbf9b [eas-cli] Bump minizlib to 3.1.0 (#4281) 2026-08-28 15:45:27 -07:00
Brent Vatne 3cb029147e [eas-cli] Prompt for Expo SDK version in eas new (#4301) 2026-08-28 12:05:38 -07:00
expo-tuft[bot]andabbanmustafa d48cfbba69 [build-tools] Normalize extracted project permissions (#4287)
* [build-tools] Normalize extracted project permissions

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

* [build-tools] Add project permission fix changelog

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

* [build-tools] Log project permission normalization

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

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: abbanmustafa <[email protected]>
2026-08-28 12:51:33 -04:00
Robin Gullo 07b5c0daa3 [build-tools] Support pnpm install --filter to install scoped dependencies (#4302)
# Why

`EAS_YARN_FOCUS_WORKSPACE` ([expo/eas-build#598](https://github.com/expo/eas-build/pull/598)) and `EAS_BUN_FILTER_WORKSPACE` ([expo/eas-cli#4292](https://github.com/expo/eas-cli/pull/4292)) let monorepos install only the dependencies of the app being built, so `node_modules` stays slim, the install is faster, and the fingerprint matches what you get locally. pnpm can do the same thing with [`pnpm install --filter <workspace>`](https://pnpm.io/filtering), but there is no way to reach it from EAS today — the pnpm branch always installs every workspace.

# How

Set it in the build profile's `env` in `eas.json`:

```json
{
  "build": {
    "production": {
      "env": {
        "EAS_PNPM_FILTER_WORKSPACE": "my-app"
      }
    }
  }
}
```

The install step then runs `pnpm install --filter my-app`. This is the pnpm counterpart of `EAS_YARN_FOCUS_WORKSPACE`, introduced in expo/eas-build#598, and `EAS_BUN_FILTER_WORKSPACE`, introduced in expo/eas-cli#4292.

# Test Plan

Added two unit tests for the flag set/unset — `yarn jest-unit src/common/__tests__/installDependencies.test.ts --runInBand` in `packages/build-tools`:

```
Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
```

Also ran the resulting commands by hand with pnpm 10.32.1, in a workspace with `pkg-a` (depends on `is-odd`), `pkg-b` (depends on `lodash`) and an in-sync `pnpm-lock.yaml`:

| command | installed |
| --- | --- |
| `pnpm install --frozen-lockfile` | both `packages/a/node_modules/is-odd` and `packages/b/node_modules/lodash` |
| `pnpm install --filter pkg-a --frozen-lockfile` | only `packages/a/node_modules/is-odd`, `pnpm-lock.yaml` unchanged |
2026-08-28 18:32:10 +02:00
David Mokos b78c84af2c updates agent cli detector to 0.1.7 (#4272) 2026-08-28 17:31:20 +02:00
Phil Pluckthun dfaa2df90d [eas-cli] Add dynamic retries for deploy command (#4299) 2026-08-28 16:12:40 +01:00
Expo CI 2e2aaa4ac9 v23.0.0 2026-08-28 11:15:00 +00:00
Szymon Dziedzic 3ef0f74110 [eas-cli] Rename serve-sim session type to web-preview-only (#4298)
* [eas-cli] Rename simulator web preview session type

* [eas-cli] Add simulator flag rename changelog

* [eas-cli] Clarify preview-only simulator type
2026-08-28 13:12:33 +02:00
Stanisław Chmiela 51ba7198d7 [build-tools] Ignore minimum release age for internal npx (#4296)
# Why

When a user defines a min package age policy a fresh release of `eas-cli` may fail.

Reported in [the internal support thread](https://exponent-internal.slack.com/archives/C09H6RFAX27/p1787835815444139).

# How

Added `NPM_CONFIG_MIN_RELEASE_AGE=0` to NPM environment to override min release age to 0.

# Test Plan

Codex verified with different npx versions.
2026-08-27 18:19:03 +00:00
Szymon Dziedzic e10e6a2235 [build-tools] Lower serve-sim preview resolution (#4297)
* [build-tools] Lower serve-sim preview resolution

* [build-tools] Add serve-sim resolution changelog
2026-08-27 19:10:56 +02:00
Konrad Armatys 17f84cf7d6 [build-tools] Support bun install --filter to install scoped dependencies (#4292)
# Why

`EAS_YARN_FOCUS_WORKSPACE` ([expo/eas-build#598](https://github.com/expo/eas-build/pull/598)) lets a monorepo install only the dependencies of the app being built, so `node_modules` stays slim, the install is faster, and the fingerprint matches what you get locally. Bun can do the same thing with `bun install --filter <workspace>` ([oven-sh/bun#16093](https://github.com/oven-sh/bun/pull/16093)), but there is no way to reach it from EAS today — the bun branch always installs every workspace.

# How

Set it in the build profile's `env` in `eas.json`:

```json
{
  "build": {
    "production": {
      "env": {
        "EAS_BUN_FILTER_WORKSPACE": "my-app"
      }
    }
  }
}
```

The install step then runs `bun install --filter my-app`. This is the bun counterpart of `EAS_YARN_FOCUS_WORKSPACE`, introduced in expo/eas-build#598.

# Test Plan

Added two unit tests for the flag set/unset — `yarn jest-unit src/common/__tests__/installDependencies.test.ts` in `packages/build-tools`:

```
Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
```

Also ran the resulting commands by hand, in a workspace with `pkg-a` (depends on `is-odd`), `pkg-b` (depends on `lodash`) and an in-sync `bun.lock`:

| command | installed |
| --- | --- |
| `bun install --frozen-lockfile` | 4 packages, `packages/b/node_modules/lodash` present |
| `bun install --filter pkg-a --frozen-lockfile` | 2 packages, only `packages/a/node_modules/is-odd`, `bun.lock` unchanged |
2026-08-27 18:41:24 +02:00
Mustafa Abban f7d9b7b297 [build-tools] support remote CocoaPods caching (#4266)
* [build-tools] Cache CocoaPods dependencies between iOS builds

* restore/save cache

* remove CocoaPods download cache

* remove no-lockfile cache key
2026-08-27 10:20:11 -04:00
Stanisław Chmiela b287d7a4e0 [worker] Use cache API for the legacy cache (#3860)
The legacy cache does not work in `workflow-orchestration`.

This rewrites the `CacheManager` to the new/not-so-new Turtle caches API.

## Summary
- replace worker build cache restore/save GCS config URLs with turtle cache download/upload helpers
- derive stable build cache keys without mutating job.cache and preserve clear for force uploads
- keep existing tar archive restore/save semantics and add CacheManager unit coverage

## Test plan
- cd packages/worker && yarn test CacheManager.test.ts
- cd packages/worker && yarn typecheck
- cd packages/build-tools && yarn typecheck
- git diff --check
2026-08-27 15:44:28 +02:00
Douglas Lowder d9d1b91fa5 [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow (#4290) 2026-08-27 04:16:20 -07:00
Expo CI a7436edb50 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-26 23:54:31 +00:00
Expo CI 89fe2cb3e1 v22.6.0 2026-08-26 23:49:36 +00:00
Szymon Dziedzic 0e8e8cc0aa [build-tools] Increase serve-sim bitrate to 6 Mbps (#4291)
* [build-tools] Increase serve-sim bitrate to 16 Mbps

* [build-tools] Add serve-sim bitrate changelog entry

* [build-tools] Set serve-sim bitrate to 6 Mbps
2026-08-27 01:45:23 +02:00
Gabe Debes 6d7cef80a3 [eas-cli] add experimental support to resource class on simulator sessions (#4268)
* [eas-cli] let simulator sessions request a large resource class

* [eas-cli] add changelog for hidden --resource-class

* [eas-cli] call --resource-class experimental in the changelog

* [eas-cli] oxfmt simulator resource-class test

* [eas-cli] regenerate graphql for resource class
2026-08-26 23:34:56 +00:00
Gabe Debes 5a744b73f5 [worker] Wire SSH session into build (#4031)
* [worker] wire ssh session into build

* Rename SSH phase sessionPromise to done.

Same anti-flatten object wrapper; clearer that callers await tunnel teardown.

* Cover SSH phase supervise callbacks and teardown failure paths.

* [worker] Stop baking upterm into the worker package

build-tools resolves upterm from PATH or downloads darwin-arm64 /
linux-amd64 from gs://turtle-v2/upterm at runtime.

* [worker] align ssh phase with build-tools api

* [worker] build the ssh target from the job type
2026-08-26 16:25:12 -07:00
Gabe Debes b8b53e262a [build-tools] Add worker-side SSH session helper (#4030)
* [build-tools] add worker-side ssh session helper

* Keep host.connectionConfig current and simplify SSH reconnect.

Update connectionConfig on every redial, linearize ensureConnectedAsync, and
parse upterm session objects without a JSON stringify round-trip.

* Reuse SshSettings instead of inlining idleTimeout/relay fields.

* Cover remaining SSH helper branches in unit tests.

Invalid upterm host URLs, missing process pid, stdout chunks, unknown
client counts during the job, and turtleBuild targets.

* Format unknownGraceMs ternary for oxfmt.

* [build-tools] Drop dup changelog entry; redact upterm exit logs

* [build-tools] gitignore baked upterm binaries

* [build-tools] Cover SSH helpers; drop upterm gitignore; restore review fixes

Zod-parse upterm session JSON and move isChildProcessAlive into processes.ts
(claimed in review but never landed). Expand unit coverage and stop gitignoring
baked upterm binaries now that the worker fetches from GCS.

* [build-tools] Resolve upterm from PATH or GCS instead of baking

Restore the orphaned review-fix tip: prefer an image/PATH upterm, else
download darwin-arm64 / linux-amd64 from gs://turtle-v2/upterm. Keeps the
stricter zod session parse, process-group/admin-socket comments, and GCS
cache/path unit coverage.

* [build-tools] Redact upterm tokens from ws:// relay URLs too

* [build-tools] Align upterm PATH probe with job env; scrub controls in logs

* [build-tools] address SSH review feedback

* [build-tools] address second round of SSH review feedback

* [build-tools] send a discriminated target on the ssh session mutation

* [build-tools] sort ssh test imports
2026-08-26 08:09:27 -07:00
Expo CI 3f74ea369d v22.5.0 2026-08-26 14:49:23 +00:00
Szymon DziedzicandClaude Fable 5 28f80da648 [eas-cli] Add --max-idle-time-minutes flag to eas simulator (#4156)
Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-26 16:39:07 +02:00
Szymon Dziedzic a7260dfed3 [eas-cli] Bump CLI environment versions to 22.4.0 (#4286) 2026-08-26 14:30:58 +02:00
Stanisław Chmiela 5cd1b5eee3 [build-tools] Preserve Argent artifact kinds (#4280)
# Why

Argent now exposes artifact kind!!

# How

Extended the schema. Kept mime-types though I'd be more than happy to remove it (mime types can lie).

# Test Plan

CI should pass.
2026-08-26 13:46:20 +02:00
Stanisław Chmiela 9d627c6917 [build-tools] Humanize Appium command summaries (#4227)
# Why

We currently present Appium session events as HTTP methods which is not too human-readable (`getWindowRect`).

# How

Added a GHA workflow which runs weekly and submits a pull request adding to an `AppiumCommand` type so TSC fails and we need to add descriptions to commands.

# Test Plan

CI should pass.
2026-08-26 13:46:07 +02:00
Szymon Dziedzic 66dbcaf432 [build-tools] Bypass simulator URL confirmation (#4274)
* [build-tools] bypass simulator URL confirmation

* [build-tools] add URL confirmation fix changelog

* [build-tools] make URL preapproval best effort
2026-08-25 00:49:48 +02:00
Expo CI 39c39ad4bf v22.4.0 2026-08-24 21:35:36 +00:00
Szymon Dziedzic 1ad3822421 [eas-cli] Add simulator launch options (#4264)
* [eas-cli] add simulator launch options

* [eas-cli] add simulator launch options changelog

* [eas-cli] resolve simulator Expo Go archives server-side

* [eas-cli] rename simulator open URL flag

* [eas-cli] rename simulator open URL GraphQL field
2026-08-24 23:33:13 +02:00
Szymon Dziedzic 0929787398 [build-tools] Support simulator application launch options (#4263)
* [build-tools] support simulator application launch options

* [build-tools] add simulator launch options changelog

* [build-tools] rename launch application open URL input

* [build-tools] clarify simulator launch arguments
2026-08-24 23:19:30 +02:00
Szymon Dziedzic 756016a433 [build-tools] Support flat app bundle archives (#4262)
* [build-tools] Support flat app bundle archives

* [build-tools] Add flat app archive changelog
2026-08-24 23:07:07 +02:00
Jon Samp c6e3c332ac [eas-cli] Add eas billing:subscribe and billing:manage commands (#4007) 2026-08-24 14:44:44 -05:00
Stanisław Chmiela 7c95a6b4b2 [build-tools] Restore maestro-runner WDA cache (#4195)
## Why

`maestro-runner` needs WDA driver to run tests. By default it compiles it on first run which takes a minute.

## How

Compiled WDA on all images, uploaded to `turtle-v2` GCS and added optimistic download to `eas/install_maestro`. See https://github.com/expo/turtle-v2/pull/2603.

## Test plan

https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/01a0009e-b171-7497-9335-af1fc470a8d5
2026-08-24 21:32:24 +02:00
Szymon Świerk 16d234b11d [steps] Report a broken composite if: on the call, not an inner step (#4216)
* [steps] Report a broken composite if: on the call, not an inner step

* [steps] Simplify condition evaluation error handling by building the erro message in the error constructor
2026-08-24 18:52:39 +02:00
Expo CI 70066fc8be v22.3.0 2026-08-24 14:45:57 +00:00
Szymon Dziedzic d27691adef [eas-cli] Add simulator build source flags (#4223)
* [eas-cli] Add simulator build source flags

* [eas-cli] align simulator application archive input

* [eas-cli] add simulator build flags changelog

* [eas-cli] add Expo Go simulator source

* [eas-cli] rename Expo Go simulator flag

* [eas-cli] rename application archive URL flag

* [eas-cli] parse Expo Go download output
2026-08-24 16:42:15 +02:00
Stanisław Chmiela 97d64f4054 [build-tools] Separate iOS ccache by destination (#4238)
# Why

Looks like ccache cache built for iOS simulator is not helpful for iOS device builds and vice versa.

Context: https://exponent-internal.slack.com/archives/C08PBDW7ZC3/p1787242902697509?thread_ts=1787171142.251299&cid=C08PBDW7ZC3

# How

Separated `ios-device-ccache-` and `ios-simulator-ccache-` ccaches.

# Test Plan

- Before: the [simulator build](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/5b9eccae-58a7-4a20-919e-61fbbd8ab68d) restored the [device build’s](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/026d0b35-0a6d-418d-bf34-488ed68f7d19) cache, got 0 hits, and could not save its own cache because the key already existed.
- After: the [device](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/02279218-8450-4679-b377-aec998dff0dd) and [simulator](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/0e6892fd-a9ee-4a90-ba23-97c7a17ad1e9) builds used separate keys. The simulator build got 48 direct hits.
2026-08-24 15:59:02 +02:00
Szymon Dziedzic 1fe8f24313 [build-tools] Install builds in simulator sessions (#4222)
* [build-tools] Install builds in simulator sessions

* [build-tools] split simulator app setup functions

* [build-tools] add simulator app setup changelog

* [build-tools] decouple application launch from artifact

* [build-tools] simplify build source validation

* [build-tools] enforce build source input type

* [build-tools] address simulator install review feedback
2026-08-24 15:08:14 +02:00
Gabe Debes 790231ea18 [build-tools] Support pinning the serve-sim version in a simulator session (#4253) 2026-08-21 17:18:48 -07:00
Gabriel Donadel Dall'Agnol 1f24d00ee9 [build-tools] Add ios_signing_backend option to the repack step (#4239)
* [build-tools] Add ios_signing_backend option to the repack step

* Update CHANGELOG.md

* split out a dedicated resolver for resolveIosSigningOptionsAsync
2026-08-21 19:58:25 +02:00
Douglas LowderandClaude Opus 4.8 3f8ffac11c [build-tools] Submit IPAs to the App Store Connect platform from the bundle (#4234)
eas submit uploaded every IPA to the iOS train because the build upload
hardcoded platform 'IOS', so tvOS binaries were validated against iOS rules
and rejected (ITMS-90508/90545/90713/90039), and their build numbers collided
with the iOS train.

Read DTPlatformName from the IPA in upload_to_asc, map it to the App Store
Connect platform (appletvos -> TV_OS, macosx -> MAC_OS, xros -> VISION_OS,
otherwise IOS), and pass it to createBuildUploadAsync and the TestFlight URL.
This also makes the duplicate-build-number precheck run against the correct
platform train. Falls back to IOS when the platform cannot be read.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-20 15:04:47 -07:00
Expo CI 137bee4db0 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-20 16:22:16 +00:00
Expo CI 5484dd6074 v22.2.0 2026-08-20 16:19:07 +00:00
Szymon Dziedzic 8fd3fe0a3a [build-tools] Rename serve-sim preview URLs (#4231)
* [build-tools] Rename serve-sim preview URLs

* [build-tools] Fix changelog attribution
2026-08-20 17:46:09 +02:00
Will Schurman 🦦 ce93a92dc8 [eas-cli] Fix lint and enforce max-warnings=0 (#4219) 2026-08-20 08:22:00 -07:00
Expo CI 3f4ed25bb9 v22.1.0 2026-08-20 14:15:53 +00:00
Stanisław Chmiela 02b54ac216 [eas-cli] Add missing changelog entry (#4236) 2026-08-20 16:09:10 +02:00
Stanisław Chmiela 4b44fecc69 [build-tools] Apply Appium event timings via default capabilities (#4235)
# Why

Appium is able to run multiple sessions against multiple devices through one server. We want all of them to get the same `eventTimings` (which is for session events integration).

# How

Moved `appium:eventTimings: true` from remote config (which only applies to the one session) to `--default-capabilities` so all sessions get it.

# Test Plan

- Verified `--default-capabilities` is a valid Appium 3 flag and that requested caps take precedence over it.
2026-08-20 15:58:12 +02:00
Stanisław Chmiela 1c082665e8 [build-tools] Allow running Maestro tests with maestro-runner (#4187)
# Why

`maestro-runner` promises to be faster, more reliable alternative to the official Maestro CLI.

# How

Added support for `EAS_MAESTRO_BACKEND` and/or input to `eas/maestro_tests` which opts in to using `maestro-runner` over `maestro` command.

`maestro-runner` doesn't support DADB, but we don't error if both are enabled.

`maestro-runner` has slightly different screenshots and JUnit format -- both are handled. It also exposes a nicer JSON file that lets us parse failed flows without having to parse JUnit file.

This adds relatively big chunk of code, but it's all guarded by `backend === maestro-runner` so maybe it's not so bad? I considered splitting logic into `MaestroCliUtils` and `MaestroRunnerUtils` and expose similar interfaces from them, but I feel it was too complicated.

# Test Plan

- Xcode 16 mixed-flow proof: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019fffe8-9bd8-74f9-87b6-d7963ee6d941
  - Automatically selected maestro-runner 1.1.15 without a workflow version input.
  - Attempt 1 ran both mixed flows.
  - `report.json` selected only `flows/mixed/fail.yml` for attempt 2.
  - Uploaded the expected failure screenshot, merged JUnit report, and complete results archive.
- Android runner success: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffcff-4dcb-724f-8671-51c5644f49e1
- Android official Maestro success: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffcf8-ffb2-7c84-bd60-682ffc4a3e7e
- Android mixed expected failure: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffcfa-6123-7f50-bbbf-42ab03560617
- iOS runner success on Xcode 26: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffd22-da8f-79dc-b9c9-3802c5694f7e
- iOS mixed expected failure on Xcode 26: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffd1e-5923-7b3b-b5cb-c8329a357e97
- iOS official Maestro success on Xcode 26: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffd2b-4b37-7601-b665-a675d39f1cdc
- Reusable proof workflows: https://github.com/expo/workflows-testing/pull/11
2026-08-19 18:06:24 +02:00
Stanisław Chmiela 7cec953e9a [build-tools] Cache Gradle file access time journal with the build cache (#4221)
# Why

It seems the cache is not getting pruned properly. It looks like Gradle uses journal to keep track of accessed files. Since we're not caching it alongside build cache, Gradle defaults all files to "they have been used now".

# How

When caching Gradle build cache, cache journal too.

# Test Plan

Verified locally.

With `build-cache-1` only kept, everything was retained after cleanup because journal inception was "now".

With both `build-cache-1` and `journal-1` kept AND inception timestamp backdated, cache was pruned.

Didn't test pruning across multiple days.
2026-08-19 17:41:23 +02:00
Stanisław Chmiela 868178fb37 [eas-cli] Add Appium simulator session support (#4190)
# Why

Now that we support Appium sessions, eas-cli needs to support it too.

# How

Added spread on GraphQL fetch, special instructions for printing and env file.

# Test Plan

CI should pass. Also ran a local e2e test.
2026-08-19 17:35:12 +02:00
Stanisław Chmiela 8439f4fbc9 [eas-cli] Protect simulator environment cleanup (#4200)
# Why

Codex insists we should clean the environment up when stopping the simulator. I think we should also guard the cleanup with ID check.

# How

Added reset to the stop command and an ID guard.

# Test Plan

CI should pass.
2026-08-19 13:09:48 +02:00
Stanisław Chmiela 78644d299f [build-tools] Add Appium remote simulator sessions (#4189)
# Why

Appium is one of the industry standards when it comes to interacting with local and remote mobile devices. I think it may be nice to add support for it to EAS Simulator.

# How

Added a new EAS function that installs Appium v3, necessary plugin, starts it and exposes its endpoint through ngrok.

The event collection is a bit wonky since we're repeatedly calling Appium endpoint for events, but maybe it's ok. Alternatively we could extend session event collection mechanism to support `getEventsAsync`, but decided not to do that (see [diff](https://github.com/expo/eas-cli/compare/8c1e165899c495981ba6bfd506bcd1444cf0c5e8..25c685b5709f66f28341d7f09cc2999d7b0885be)).

# Test Plan

Tested manually locally. A remote test is going to be better!
2026-08-19 12:38:51 +02:00
Stanisław Chmiela 0d0134dae5 [gha] Install eas-cli once in worker workflows (#4203)
# Why

Worker always fails to deploy when we release a new `eas-cli` version because the `latest` moves mid-run and NPM reports missing archive for the new version.

# How

Replaced `npx -y eas-cli@latest ...` calls with installing `eas-cli` globally once and then using `eas`.

# Test plan

Next `eas-cli` release should deploy worker ok.
2026-08-19 11:18:17 +02:00
David Mokos b03d31561c updates agent cli detector to 0.1.6 (#4218) 2026-08-18 10:02:11 -07:00
Stanisław Chmiela 0e85408741 [build-tools] Soft-stop device run sessions at time limit (#4117)
## Why

Currently, simulator sessions may end at hard 2h job run time limit (and we also override job run's "max run time seconds" to denote "max device run session run time"). This is bad because when job run exceeds its run time, it gets immediately killed and the worker cannot upload screen recordings.

## How

Added `max_duration_seconds` param to remote-session build functions. Added duration abort controller/signal and merged it with the cancel abort signal. Whichever happens first — cancel or abort — is going to cause waiting to stop and upload artifacts and exit cleanly.

Linear: https://linear.app/expo/issue/ENG-25528/soft-stop-device-run-sessions-at-time-limit

Note: this is going to lower the max run time for device run sessions in https://github.com/expo/universe/pull/29554 cause we need to stop simulator session at 2h – (time it takes to upload stuff).

## Test plan

When `max_duration_seconds` is not provided we do not do anything. So, we'll deploy this, add the param and see if things are ok.
2026-08-18 16:47:43 +02:00
Szymon Dziedzic 4107b7e611 Respect Expo admins in simulator availability (#4181) 2026-08-18 13:28:39 +02:00
Szymon DziedzicandClaude Fable 5 6cbddf5b8e [eas-cli] Add --device flag to eas simulator (#4172)
* [eas-cli] Add --device flag to eas simulator

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

* Update CHANGELOG.md

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

* [eas-cli] Regenerate GraphQL schema

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 11:40:44 +02:00
Stanisław Chmiela a3f4136ac8 [build-tools] Select compatible maestro-runner version for Xcode (#4193)
# Why

`maestro-runner` 1.1.16+ is not compatible with Xcodes older than 26 because when selecing build target it adds an `arch` filter which older Xcodes don't recognize.

# How

Made it so we install 1.1.15 for older Xcodes and 1.1.16+ for new Xcodes.

# Test Plan

- Verified automatic version selection on the Xcode 16.2 staging image without a `maestro_version` input. The worker detected Xcode 16 and installed maestro-runner 1.1.15: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019fffe8-9bd8-74f9-87b6-d7963ee6d941
- Reproduced the unsupported `arch` failure with maestro-runner 1.1.23 on the same image: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019ffd14-cb98-7351-9113-872d01c557ae
- Successful run: https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019fff26-4055-7ae5-8f64-8c3b9190c380
2026-08-18 11:31:21 +02:00
Stanisław Chmiela 6926209e53 [build-tools] Capture Android emulator process output (#4197)
## Why

We now provide nice logcat output, but we never supported `emulator` output.

## How

Added piping of the `emulator` command to a new `.log` file in the same directory we put logcat files.

## Test plan

Tested locally.
2026-08-18 10:55:47 +02:00
Stanisław Chmiela efc97f3bdb [build-tools] Fix 1.1.8 idb_companion pointer (#4220)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

1.1.8 [has issue with installation](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a0110d-e8f1-77d1-abed-08ff02f79941), later fixed with https://github.com/facebook/homebrew-fb/commit/c0386793f59da10c619787f2aa18d938ef1d69c9.

# How

Move idb commit pointer.

# Test Plan

Deployed to staging and verified (link incoming).
- [modern image](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a01111-1308-702d-b5f1-6f35e623e470)
- [older image](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a01118-2dff-780b-aa3b-164ebbdfcf7b)
2026-08-17 21:09:42 +02:00
Gabe Debes f72cff08a1 [eas-cli] Accept a major SDK version in eas go (#4215)
* [eas-cli] Resolve the eas go SDK version to its repack target

* [eas-cli] Add changelog entry
2026-08-17 11:18:42 -07:00
Stanisław Chmiela 7b98b074f4 [build-tools] Fix idb_companion existence check (#4217)
# Why

Facebook released a new version of IDB which breaks Maestro tests.

# How

Fixed existence check since for a few images we've been preinstalling `idb_companion`, but checking for it bad.

Also changed how we install `idb_companion` from trusting latest from Homebrew to using a specific checkout.

# Test Plan

Tested manually, locally.
2026-08-17 17:49:02 +00:00
Will Schurman 🦦 40b02895f7 [eas-cli] Handle missing fingerprint source in fingerprint:compare command (#4210) 2026-08-17 10:17:05 -07:00
Szymon Świerk 1f4c1edd99 [steps] Composite functions: skip the outputs step when the call never ran (#4207)
* [steps] Composite functions: skip the outputs step when the call never ran

* [steps] test coverage improvement: recursive anyAuthoredChildExecuted when a nested call has no outputs
2026-08-17 13:40:32 +02:00
Will Schurman 🦦 2f320aea43 [eas-cli] Enforce uploaded file integrity with GCS MD5 (#4208) 2026-08-14 14:49:43 -07:00
Will Schurman 🦦 f41696701e [eas-cli] Disallow 0-length uploads for fingerprints (#4192) 2026-08-14 11:54:37 -07:00
Expo CI a9d693d867 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-14 14:36:30 +00:00
Expo CI b92a9cc5a7 v22.0.0 2026-08-14 14:24:53 +00:00
763cbadaaa [eas-cli] remove deprecated GraphQL field usage (#4021)
* Remove deprecated GraphQL field usage

* Keep flat runtimeVersion in update:republish --json output

* Revert "Keep flat runtimeVersion in update:republish --json output"

This reverts commit 32264aea2d5c200bba7ee6be46d80598433fee08.

* Clarify CHANGELOG entry for update:republish --json shape

* Limit breaking-changes note to user-facing output changes

* Migrate account fixtures in accountSelection tests

* Migrate remaining account fixtures in getProjectIdAsync tests

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

* [eas-cli] Restore submission Application Identifier from the submitted build

Read it from Build.appIdentifier instead of the deprecated
androidConfig.applicationIdentifier.

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

* Regenerate GraphQL code and update channel fixture after rebase

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 17:21:27 +03:00
618 changed files with 75054 additions and 9757 deletions

No files matched your search

@@ -27,6 +27,9 @@ jobs:
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
@@ -61,7 +64,7 @@ jobs:
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
@@ -96,7 +99,7 @@ jobs:
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
@@ -181,7 +184,7 @@ jobs:
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: always()
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
with:
message-id: changelog-entry-check
refresh-message-position: true
+113 -23
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
@@ -37,20 +45,6 @@ jobs:
run: yarn build
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Verify eas-cli was published as latest on npm
run: |
expected="$(jq -r .version lerna.json)"
for attempt in $(seq 1 10); do
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
if [ "$published" = "$expected" ]; then
echo "Verified eas-cli@$expected is latest on npm."
exit 0
fi
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
sleep 15
done
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
exit 1
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
@@ -61,34 +55,130 @@ 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
run: yarn install --immutable
- 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: 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
- name: Commit and push changelog
run: |
git fetch
git checkout main
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
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_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)"
if [ "$published" = "$EAS_CLI_VERSION" ]; then
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
exit 0
fi
current_epoch=$(date +%s)
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
break
fi
retry_delay=$((availability_deadline_epoch - current_epoch))
if [ "$retry_delay" -gt 10 ]; then
retry_delay=10
fi
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
sleep "$retry_delay"
done
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: |
git pull --rebase --autostash origin main
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add cli-versions.json CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
git push
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
- name: Commit and push version update
run: |
git add cli-versions.json
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'
+31
View File
@@ -0,0 +1,31 @@
name: test-egress-guard
on:
workflow_dispatch: {}
pull_request:
paths:
- packages/build-tools/resources/egress-guard/**
- packages/build-tools/src/steps/utils/localEgressGuard.ts
- packages/build-tools/src/steps/utils/__tests__/localEgressGuard*.ts
- packages/build-tools/src/steps/functions/startIosSimulator.ts
- packages/worker/.eas/workflows/test-egress-guard.yml
- .github/workflows/test-egress-guard.yml
jobs:
test-egress-guard:
# The simulator tests need macOS with Xcode, which the EAS macOS workers have.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- name: Run the test-egress-guard EAS workflow on macOS
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
eas workflow:run test-egress-guard.yml \
--ref ${{ github.event.pull_request.head.sha || github.sha }} \
--non-interactive \
--wait
+5 -2
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22', '24']
node: ['20.18.3', '22', '24']
include:
- node: '20'
- node: '20.18.3'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -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:
@@ -0,0 +1,90 @@
name: Update Appium command list
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
push:
branches:
- main
paths:
- .github/workflows/update-appium-commands.yml
workflow_dispatch:
permissions:
contents: read
jobs:
generate:
runs-on: ubuntu-latest
name: Regenerate Appium command list
outputs:
changed: ${{ steps.diff.outputs.changed }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --immutable
- name: Regenerate the Appium command list
# The generator installs Appium, XCUITest, and UiAutomator2 on demand,
# so it always picks up the latest supported command sets.
run: yarn workspace @expo/build-tools generate-appium-commands
- name: Check whether the command list changed
id: diff
run: |
if git diff --quiet -- packages/build-tools/src/steps/utils/appiumCommands.generated.ts; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
- name: Upload the generated command list
if: steps.diff.outputs.changed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: appium-command-list
path: packages/build-tools/src/steps/utils/appiumCommands.generated.ts
if-no-files-found: error
retention-days: 1
publish:
needs: generate
if: needs.generate.outputs.changed == 'true'
runs-on: ubuntu-latest
name: Publish Appium command list update
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Download the generated command list
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: appium-command-list
path: packages/build-tools/src/steps/utils
- name: Open a pull request
env:
GH_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
run: |
branch=bot/update-appium-commands
title='[build-tools] Update Appium command list'
body=$'Automated update of the Appium, XCUITest, and UiAutomator2 command list.\n\nIf this PR adds new commands, typechecking will fail until a curated summary is added for each of them in `packages/build-tools/src/steps/utils/appiumCommandSummary.ts`.'
gh auth setup-git
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
fi
git config user.name 'Expo Bot'
git config user.email [email protected]
git checkout -B "$branch"
git add packages/build-tools/src/steps/utils/appiumCommands.generated.ts
git commit -m "$title"
git push --force-with-lease origin "$branch"
if gh pr view "$branch" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
gh pr edit "$branch" --title "$title" --body "$body" --add-reviewer sjchmiela
else
gh pr create --base main --head "$branch" --title "$title" --body "$body" \
--label 'no changelog' --reviewer sjchmiela
fi
+13 -10
View File
@@ -15,12 +15,15 @@ jobs:
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
- name: Link to EAS project
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
run: eas init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
- name: Add bundle identifiers to app.json
working-directory: ${{ runner.temp }}/test-app
@@ -30,7 +33,7 @@ jobs:
- name: Configure EAS Build
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest build:configure --platform all
run: eas build:configure --platform all
- name: Add system-test profile to eas.json
working-directory: ${{ runner.temp }}/test-app
@@ -49,7 +52,7 @@ jobs:
run: |
set -euo pipefail
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_JSON=$(eas build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
if [ -z "$BUILD_ID" ]; then
echo "Error: could not determine build ID"
@@ -59,7 +62,7 @@ jobs:
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
echo "Resolved BUILD_ID=$BUILD_ID"
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_JSON=$(eas workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: could not determine workflow run ID"
@@ -95,8 +98,8 @@ jobs:
exit 1
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json | jq -r '.status // empty')
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
echo "iOS build status: $BUILD_STATUS"
echo "Android workflow status: $WORKFLOW_STATUS"
@@ -130,13 +133,13 @@ jobs:
if [ -n "${BUILD_ID:-}" ]; then
echo "Canceling EAS build: $BUILD_ID"
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
BUILD_CANCEL_OUTPUT=$(eas build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
echo "$BUILD_CANCEL_OUTPUT"
else
echo "build:cancel produced no output"
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$BUILD_STATUS" ]; then
echo "Post-cancel build status: $BUILD_STATUS"
else
@@ -145,13 +148,13 @@ jobs:
fi
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
WORKFLOW_CANCEL_OUTPUT=$(eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
echo "$WORKFLOW_CANCEL_OUTPUT"
else
echo "workflow:cancel produced no output"
fi
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$WORKFLOW_STATUS" ]; then
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
else
+325 -2
View File
@@ -8,16 +8,338 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
### 🧹 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] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
- [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. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
## [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))
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
### 🛠 Breaking changes
- [eas-cli] Require Node `^20.18.3 || >=22.0.0`. ([#4279](https://github.com/expo/eas-cli/pull/4279) by [@ramonclaudio](https://github.com/ramonclaudio))
### 🎉 New features
- [build-tools] Inject a guard into every iOS Simulator process during `--egress local` sessions that refuses connections which bypass the system proxy and reports them, with the calling frameworks, in the session log. ([#4393](https://github.com/expo/eas-cli/pull/4393) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
### 🐛 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))
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
### 🎉 New features
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
### 🎉 New features
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
### 🧹 Chores
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
### 🐛 Bug fixes
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
### 🎉 New features
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
### 🛠 Breaking changes
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
### 🐛 Bug fixes
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
### 🛠 Breaking changes
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
### 🎉 New features
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
### 🐛 Bug fixes
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
### 🎉 New features
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp))
- [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Increase the serve-sim video bitrate to 6 Mbps for higher-quality simulator previews. ([#4291](https://github.com/expo/eas-cli/pull/4291) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
### 🎉 New features
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
### 🐛 Bug fixes
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
## [22.3.0](https://github.com/expo/eas-cli/releases/tag/v22.3.0) - 2026-08-24
### 🎉 New features
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
### 🎉 New features
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Disallow 0-length uploads. ([#4192](https://github.com/expo/eas-cli/pull/4192) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Fix lint and enforce max-warnings=0. ([#4219](https://github.com/expo/eas-cli/pull/4219) by [@wschurman](https://github.com/wschurman))
- [build-tools] Rename serve-sim ngrok preview URLs to use the `web-preview` prefix. ([#4231](https://github.com/expo/eas-cli/pull/4231) by [@szdziedzic](https://github.com/szdziedzic))
## [22.0.0](https://github.com/expo/eas-cli/releases/tag/v22.0.0) - 2026-08-14
### 🛠 Breaking changes
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
### 🎉 New features
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
### 🐛 Bug fixes
- [eas-cli] Report EAS Simulator as available to Expo admins in `eas simulator:availability`, even when the project account's feature gate is disabled. ([#4181](https://github.com/expo/eas-cli/pull/4181) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
### 🧹 Chores
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
### 🎉 New features
@@ -79,6 +401,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Soft-stop EAS Simulator sessions before the backing job times out so cleanup can finish. ([#4117](https://github.com/expo/eas-cli/pull/4117) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
+1 -1
View File
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-07-15
Change Date: 2029-10-01
Change License: MIT
+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": "21.8.0",
"PRODUCTION": "21.5.0"
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.8.0",
"version": "24.9.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -4
View File
@@ -12,7 +12,7 @@
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"actionlint": "actionlint",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
@@ -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]"
}
+2
View File
@@ -8,3 +8,5 @@ bin/record-sim
src/graphql-env.d.ts
schema.graphql
bin/egress-guard.dylib
bin/egress-guard-check
+20 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.8.0",
"version": "24.9.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -24,7 +24,10 @@
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -38,31 +41,33 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.6.0",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.9.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.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": "21.7.1",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/steps": "24.9.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",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"content-disposition": "1.0.1",
"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",
@@ -73,15 +78,17 @@
"semver": "^7.6.2",
"tar": "7.5.19",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.6.1",
"@expo/repack-app": "~0.9.0",
"@types/content-disposition": "0.5.9",
"@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",
@@ -89,15 +96,15 @@
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"bunyan": "^1.8.15",
"@types/ws": "8.5.10",
"jest": "^29.7.0",
"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"
}
}
@@ -0,0 +1,83 @@
# egress-guard
A small dylib injected into every process the iOS Simulator launches during a
`--egress local` session. It interposes the socket calls that open outbound
traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
variants of the last three that libsystem exports alongside them) and refuses
any destination that is not loopback. A sockaddr whose family was left
`AF_UNSPEC` is classified as the family its length implies, which is how the
kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
`connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
forwards live on loopback, so everything that honors the proxy keeps working
and everything that bypasses it fails with `ECONNREFUSED` in the process that
tried, with the calling frameworks recorded.
Why interposing: simulator processes share the worker's uid, so a packet
filter cannot tell them apart from the worker's own traffic. dyld interposing
from an inserted library reaches calls made inside Apple's own frameworks
(CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
inside the simulator makes launchd hand the library to every process it
spawns. The simulator does not enforce library validation, which is what makes
this possible there and nowhere else.
Configuration comes from the environment the simulator's launchd provides:
- `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
- `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
or `log` (observe only).
Each event is one tab-separated line:
```
eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
```
A process lists each `(function, peer)` once, up to 128 of them. When that
table fills it writes one more line with function `overflow` and the limit as
the peer; further distinct destinations are still refused but not listed.
Layout:
- `policy.c` / `policy.h`: classification, mode, formatting, per-process
dedupe. Pure C, host-testable.
- `guard.c`: the interposers and the constructor that reads the environment.
- `check.c`: `egress-guard-check`, run inside the simulator right after the
guard is installed. Exits non-zero unless the library is loaded in a fresh
process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
`connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
- `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
- `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
interposers themselves, `tests/run-guard-tests.sh`. The first compiles
`guard.c` in and forces the lock schedules (a signal handler making a socket
call inside the critical section, a fork while another thread holds the lock,
contention); the second inserts the built library like the simulator does and
checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
UDP disconnect. Nothing in them opens a socket to a non-loopback address.
- `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
- `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
variable of its own environment to the process it starts, and for `simctl
boot` that process is the simulator's launchd, so the worker boots with the
guard and proxy variables in that form and every process of the boot inherits
them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
device that was already booted, but by then the boot's own processes have
started without it. It also cannot change a variable the boot already carried:
a device booted with one guard configuration keeps it until it is shut down,
which is why the worker boots devices itself and every configuration in the
end-to-end test gets its own boot. The self-check runs once boot completes and is followed
by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
refusals still happen and only the reporting is lost. Recording is best-effort
in the process too: the event log is opened per event rather than held open
(a process that closes descriptors it does not own, as `launchd_sim` does,
would otherwise make the guard write into whatever reused the number), and a
dedupe slot that cannot be taken within a bounded number of attempts (a signal
handler re-entering the guard, a fork child inheriting a held lock) skips the
event rather than blocking or aborting the process.
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
# packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
set -euo pipefail
cd "$(dirname "$0")"
# Output directory; defaults to packages/build-tools/bin.
bin_dir="${1:-../../bin}"
mkdir -p "$bin_dir"
out=$(mktemp -d)
sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
for arch in arm64 x86_64; do
xcrun --sdk iphonesimulator clang \
-std=c11 -Wall -Wextra -Werror -O2 \
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
-dynamiclib -install_name @rpath/egress-guard.dylib \
-o "$out/egress-guard-$arch.dylib" policy.c guard.c
done
lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
lipo -info "$bin_dir/egress-guard.dylib"
# The self-check the worker runs inside the simulator after installing the guard.
for arch in arm64 x86_64; do
xcrun --sdk iphonesimulator clang \
-std=c11 -Wall -Wextra -Werror -O2 \
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
-o "$out/egress-guard-check-$arch" check.c
done
lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
chmod +x "$bin_dir/egress-guard-check"
lipo -info "$bin_dir/egress-guard-check"
@@ -0,0 +1,112 @@
// Self-check run inside a simulator right after the guard is installed:
// proves the guard library is loaded in a freshly spawned process and that it
// behaves as the requested mode says, on every connect entry point the guard
// covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
// loaded but did not behave; 1 on usage errors.
//
// egress-guard-check --mode block|log
#include <arpa/inet.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <mach-o/dyld.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
// TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
// connect reports EINPROGRESS and nothing ever answers.
#define PROBE_ADDRESS "192.0.2.1"
#define PROBE_PORT 9
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
static int guard_loaded(void) {
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
const char *name = _dyld_get_image_name(i);
if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
return 1;
}
}
return 0;
}
// One non-blocking connect to the probe address through `entry`, with the
// sockaddr family the caller wants. Returns the connect result; errno is
// stored in *err.
static int probe(connect_fn entry, sa_family_t family, int *err) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
*err = errno;
return -2;
}
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
struct sockaddr_in to;
memset(&to, 0, sizeof to);
to.sin_family = family;
to.sin_len = sizeof to;
to.sin_port = htons(PROBE_PORT);
inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
*err = errno;
close(fd);
return rc;
}
int main(int argc, char **argv) {
const char *mode = "block";
if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
mode = argv[2];
} else if (argc != 1) {
fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
return 1;
}
if (!guard_loaded()) {
printf("egress-guard-check: the guard library is not loaded in this process\n");
return 2;
}
connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
if (connect_nocancel == NULL) {
printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
return 3;
}
// The plain call, the non-cancelable variant, and a sockaddr whose family
// was left AF_UNSPEC, which the kernel connects as IPv4.
const struct {
const char *name;
connect_fn entry;
sa_family_t family;
} probes[] = {
{"connect", connect, AF_INET},
{"connect$NOCANCEL", connect_nocancel, AF_INET},
{"connect with AF_UNSPEC", connect, AF_UNSPEC},
};
int block = strcmp(mode, "block") == 0;
for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
int err = 0;
int rc = probe(probes[i].entry, probes[i].family, &err);
if (rc == -2) {
printf("egress-guard-check: socket() failed: %s\n", strerror(err));
return 3;
}
if (block && !(rc == -1 && err == ECONNREFUSED)) {
printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
probes[i].name, rc, err, strerror(err));
return 3;
}
if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
probes[i].name, err, strerror(err));
return 3;
}
}
if (block) {
printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
} else {
printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
}
return 0;
}
@@ -0,0 +1,274 @@
// The local egress guard: interposes the calls that open outbound traffic in
// every simulator process it is inserted into, refuses non-loopback
// destinations in block mode, and records one event per destination per
// process. See README.md and policy.h.
#include "policy.h"
#include <dlfcn.h>
#include <errno.h>
#include <execinfo.h>
#include <fcntl.h>
#include <os/lock.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
#define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
#define EG_MAX_CALLERS 6
#define EG_CALLER_LENGTH 64
#define EG_LOCK_ATTEMPTS 64
static int eg_initialized = 0;
static void *eg_own_image = NULL;
static char eg_log_path[1024];
static eg_mode_t eg_mode = EG_MODE_BLOCK;
static eg_seen_t eg_seen;
static int eg_overflow_reported = 0;
static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
static void eg_init(void) {
if (eg_initialized) {
return;
}
eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
Dl_info self;
if (dladdr((const void *)eg_init, &self)) {
eg_own_image = self.dli_fbase;
}
const char *log_path = getenv(EG_LOG_ENV);
// The path is kept rather than an open descriptor: a process that closes
// descriptors it does not know about (launchd_sim does) would otherwise
// leave the guard writing into whatever file reused the number.
if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
strcpy(eg_log_path, log_path);
}
eg_initialized = 1;
}
__attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
// Image names of the frames above the guard's own, innermost first, with
// consecutive repeats collapsed: "Network,CFNetwork,MyApp".
__attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
void *frames[EG_MAX_CALLERS + 6];
int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
int count = 0;
for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
Dl_info info;
const char *image = "?";
if (dladdr(frames[i], &info)) {
if (info.dli_fbase == eg_own_image) {
continue;
}
if (info.dli_fname != NULL) {
const char *slash = strrchr(info.dli_fname, '/');
image = slash ? slash + 1 : info.dli_fname;
}
}
if (count > 0 && strcmp(names[count - 1], image) == 0) {
continue;
}
strncpy(names[count], image, EG_CALLER_LENGTH - 1);
names[count][EG_CALLER_LENGTH - 1] = '\0';
count++;
}
return count;
}
// A lock held by this same thread (a signal handler making a socket call
// while the interrupted call held it) or by a thread that did not survive
// fork() never becomes available and os_unfair_lock_lock aborts the process
// in both cases. Give up on recording the event instead; refusal does not
// depend on it. Ordinary contention over the microsecond-long critical
// section resolves within the first attempts.
static int eg_lock_bounded(void) {
for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
if (os_unfair_lock_trylock(&eg_lock)) {
return 1;
}
sched_yield();
}
return 0;
}
// One line in the event log. O_APPEND keeps whole lines intact across
// processes writing concurrently; the log is opened per event, at most
// EG_SEEN_CAPACITY + 1 times per process.
__attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
const char *peer, int with_callers) {
if (eg_log_path[0] == '\0') {
return;
}
char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
const char *callers[EG_MAX_CALLERS];
int caller_count = with_callers ? eg_collect_callers(names) : 0;
for (int i = 0; i < caller_count; i++) {
callers[i] = names[i];
}
char line[1024];
int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
callers, caller_count);
if (n <= 0) {
return;
}
int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
if (fd >= 0) {
(void)write(fd, line, (size_t)n);
close(fd);
}
}
// Returns 1 when the call must be refused.
__attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
socklen_t len) {
eg_class_t cls = eg_classify(sa, len);
if (cls != EG_REMOTE) {
return 0;
}
eg_init();
int deny = eg_should_deny(eg_mode, cls);
char peer[96];
if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
strncpy(peer, "?", sizeof peer);
}
char key[EG_SEEN_KEY_LENGTH];
snprintf(key, sizeof key, "%s %s", function, peer);
int fresh = 0;
int overflowed = 0;
if (eg_lock_bounded()) {
fresh = eg_seen_insert(&eg_seen, key);
if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
eg_overflow_reported = 1;
overflowed = 1;
}
os_unfair_lock_unlock(&eg_lock);
}
const char *action = deny ? "blocked" : "logged";
if (fresh) {
eg_write_event(function, action, peer, 1);
} else if (overflowed) {
// Once per process: the table is full, so further distinct destinations
// are still refused but no longer listed.
char limit[64];
snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
}
return deny;
}
// connect() with an AF_UNSPEC address on a datagram socket dissolves the
// association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
// to the kernel. On a stream socket the kernel connects to the address the
// length implies, which eg_handle classifies.
static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
return 0;
}
int type = 0;
socklen_t type_len = sizeof type;
return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
}
typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
socklen_t);
typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
// The non-cancelable variants libsystem_kernel exports next to the cancelable
// ones; dyld interposing is per symbol, so each needs its own entry.
extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
"_connect$NOCANCEL");
extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
const struct sockaddr *, socklen_t) __asm__(
"_sendto$NOCANCEL");
extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
"_sendmsg$NOCANCEL");
static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
socklen_t len) {
if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
errno = ECONNREFUSED;
return -1;
}
return original(fd, sa, len);
}
static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
return eg_connect_through(connect, fd, sa, len);
}
static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
}
static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
size_t *len, sae_connid_t *connid) {
if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
!eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
errno = ECONNREFUSED;
return -1;
}
return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
}
static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
int flags, const struct sockaddr *sa, socklen_t len) {
if (sa != NULL && eg_handle("sendto", sa, len)) {
errno = ECONNREFUSED;
return -1;
}
return original(fd, buf, n, flags, sa, len);
}
static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
socklen_t len) {
return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
}
static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
const struct sockaddr *sa, socklen_t len) {
return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
}
static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
int flags) {
if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
errno = ECONNREFUSED;
return -1;
}
return original(fd, msg, flags);
}
static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
return eg_sendmsg_through(sendmsg, fd, msg, flags);
}
static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
}
// dyld applies these to every image in the process, including the shared
// cache, which is how calls made inside CFNetwork and Network.framework are
// caught.
__attribute__((used)) static const struct {
const void *replacement;
const void *original;
} eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
{(const void *)eg_connect, (const void *)connect},
{(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
{(const void *)eg_connectx, (const void *)connectx},
{(const void *)eg_sendto, (const void *)sendto},
{(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
{(const void *)eg_sendmsg, (const void *)sendmsg},
{(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
};
@@ -0,0 +1,164 @@
#include "policy.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static int is_v4_local(uint32_t host_order) {
return (host_order >> 24) == 127 || host_order == 0;
}
// XNU accepts a sockaddr whose family is AF_UNSPEC as the family its length
// implies: TCP connect() translates it on both socket families and IPv4
// sendto()/sendmsg() deliver with it. Classify it the way the kernel uses it.
static sa_family_t eg_family(const struct sockaddr *sa, socklen_t len) {
if (sa->sa_family == AF_UNSPEC) {
if (len == sizeof(struct sockaddr_in)) {
return AF_INET;
}
if (len == sizeof(struct sockaddr_in6)) {
return AF_INET6;
}
}
return sa->sa_family;
}
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len) {
if (sa == NULL || len < 2) {
return EG_PASSTHROUGH;
}
sa_family_t family = eg_family(sa, len);
if (family == AF_INET) {
if (len < sizeof(struct sockaddr_in)) {
return EG_PASSTHROUGH;
}
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
return is_v4_local(ntohl(in->sin_addr.s_addr)) ? EG_LOOPBACK : EG_REMOTE;
}
if (family == AF_INET6) {
if (len < sizeof(struct sockaddr_in6)) {
return EG_PASSTHROUGH;
}
const struct in6_addr *a6 = &((const struct sockaddr_in6 *)sa)->sin6_addr;
if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_UNSPECIFIED(a6)) {
return EG_LOOPBACK;
}
if (IN6_IS_ADDR_V4MAPPED(a6)) {
uint32_t v4;
memcpy(&v4, &a6->s6_addr[12], sizeof v4);
return is_v4_local(ntohl(v4)) ? EG_LOOPBACK : EG_REMOTE;
}
return EG_REMOTE;
}
return EG_PASSTHROUGH;
}
eg_mode_t eg_parse_mode(const char *value) {
if (value != NULL && strcmp(value, "log") == 0) {
return EG_MODE_LOG;
}
return EG_MODE_BLOCK;
}
int eg_should_deny(eg_mode_t mode, eg_class_t cls) {
return mode == EG_MODE_BLOCK && cls == EG_REMOTE;
}
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n) {
char ip[INET6_ADDRSTRLEN];
int written;
if (sa == NULL || len < 2) {
return -1;
}
sa_family_t family = eg_family(sa, len);
if (family == AF_INET && len >= sizeof(struct sockaddr_in)) {
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
if (inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip) == NULL) {
return -1;
}
written = snprintf(out, n, "%s:%d", ip, ntohs(in->sin_port));
} else if (family == AF_INET6 && len >= sizeof(struct sockaddr_in6)) {
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa;
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip, sizeof ip) == NULL) {
return -1;
}
written = snprintf(out, n, "[%s]:%d", ip, ntohs(in6->sin6_port));
} else {
return -1;
}
return (written < 0 || (size_t)written >= n) ? -1 : 0;
}
int eg_seen_insert(eg_seen_t *seen, const char *key) {
for (int i = 0; i < seen->count; i++) {
if (strncmp(seen->keys[i], key, EG_SEEN_KEY_LENGTH - 1) == 0) {
return 0;
}
}
if (seen->count >= EG_SEEN_CAPACITY) {
seen->overflow++;
return 0;
}
strncpy(seen->keys[seen->count], key, EG_SEEN_KEY_LENGTH - 1);
seen->keys[seen->count][EG_SEEN_KEY_LENGTH - 1] = '\0';
seen->count++;
return 1;
}
// Append `text` to out[*pos], replacing field separators; returns 0 when it fit.
static int append_field(char *out, size_t n, size_t *pos, const char *text) {
for (const char *c = text; *c; c++) {
if (*pos + 1 >= n) {
return -1;
}
out[(*pos)++] = (*c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
}
return 0;
}
static int append_raw(char *out, size_t n, size_t *pos, const char *text) {
size_t len = strlen(text);
if (*pos + len + 1 > n) {
return -1;
}
memcpy(out + *pos, text, len);
*pos += len;
return 0;
}
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
const char *action, const char *peer, const char *const *callers,
int caller_count) {
size_t pos = 0;
char pid_text[16];
snprintf(pid_text, sizeof pid_text, "%d", pid);
if (append_raw(out, n, &pos, EG_EVENT_PREFIX "\t") != 0 ||
append_field(out, n, &pos, progname ? progname : "?") != 0 ||
append_raw(out, n, &pos, "\t") != 0 || append_raw(out, n, &pos, pid_text) != 0 ||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, function) != 0 ||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, action) != 0 ||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, peer) != 0 ||
append_raw(out, n, &pos, "\t") != 0) {
return -1;
}
for (int i = 0; i < caller_count && callers != NULL; i++) {
if (i > 0 && append_raw(out, n, &pos, ",") != 0) {
return -1;
}
// Commas separate callers, so they cannot appear inside one.
for (const char *c = callers[i]; *c; c++) {
char ch = (*c == ',' || *c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
if (pos + 1 >= n) {
return -1;
}
out[pos++] = ch;
}
}
if (append_raw(out, n, &pos, "\n") != 0) {
return -1;
}
out[pos] = '\0';
return (int)pos;
}
@@ -0,0 +1,60 @@
// Policy and formatting for the local egress guard. Pure functions with no
// interposing, so they compile and test on macOS as well as in the simulator.
#ifndef EAS_EGRESS_GUARD_POLICY_H
#define EAS_EGRESS_GUARD_POLICY_H
#include <stddef.h>
#include <sys/socket.h>
// What a destination address is, as far as the guard cares.
typedef enum {
EG_PASSTHROUGH = 0, // not an internet address (unix sockets, NULL, short); never touched
EG_LOOPBACK = 1, // 127.0.0.0/8, ::1, ::ffff:127.x, unspecified; the proxy and forwards live here
EG_REMOTE = 2, // anything else, including link-local and multicast
} eg_class_t;
typedef enum {
EG_MODE_BLOCK = 0, // refuse EG_REMOTE with ECONNREFUSED
EG_MODE_LOG = 1, // observe only
} eg_mode_t;
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len);
// "block" or unset selects block; "log" selects log. Anything else is block:
// an unknown mode must fail closed.
eg_mode_t eg_parse_mode(const char *value);
int eg_should_deny(eg_mode_t mode, eg_class_t cls);
// Writes "1.2.3.4:443" or "[2001:db8::1]:443". Returns 0 on success.
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n);
// Fixed-capacity set of strings, so a retrying client logs a destination once
// per process instead of once per attempt.
#define EG_SEEN_CAPACITY 128
#define EG_SEEN_KEY_LENGTH 192
typedef struct {
char keys[EG_SEEN_CAPACITY][EG_SEEN_KEY_LENGTH];
int count;
int overflow; // insertions refused because the table was full
} eg_seen_t;
// 1 when the key was not present and was inserted; 0 when already present or
// when the table is full (counted in overflow).
int eg_seen_insert(eg_seen_t *seen, const char *key);
#define EG_EVENT_PREFIX "eas-egress-guard"
// Function name of the one event a process writes when its seen table fills:
// from then on distinct destinations are still refused but no longer listed.
#define EG_OVERFLOW_FUNCTION "overflow"
// One tab-separated line, newline terminated:
// eas-egress-guard\t<progname>\t<pid>\t<function>\t<action>\t<peer>\t<caller>,<caller>...\n
// Tabs and newlines inside fields are replaced with spaces. Returns the length
// written, or -1 when it does not fit.
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
const char *action, const char *peer, const char *const *callers,
int caller_count);
#endif
@@ -0,0 +1,291 @@
// Black-box host tests of the built guard, loaded through
// DYLD_INSERT_LIBRARIES like the simulator does. Remote calls use fd -1 or are
// refused before the kernel; the only real sockets are on loopback. Build and
// run with tests/run-guard-tests.sh.
//
// guard-insert-test <case> <log-path> [<extra-path>]
#include <arpa/inet.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/uio.h>
#include <unistd.h>
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
typedef ssize_t (*sendto_fn)(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
typedef ssize_t (*sendmsg_fn)(int, const struct msghdr *, int);
static struct sockaddr_in remote(int port) {
struct sockaddr_in a;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_len = sizeof a;
a.sin_port = htons(port);
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
return a;
}
static struct sockaddr_in6 remote6(int port) {
struct sockaddr_in6 a;
memset(&a, 0, sizeof a);
a.sin6_family = AF_INET6;
a.sin6_len = sizeof a;
a.sin6_port = htons(port);
inet_pton(AF_INET6, "2001:db8::1", &a.sin6_addr);
return a;
}
static int refused_sendto(int port) {
struct sockaddr_in to = remote(port);
errno = 0;
return sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) == -1 && errno == ECONNREFUSED;
}
static off_t file_size(const char *path) {
struct stat st;
return stat(path, &st) == 0 ? st.st_size : -1;
}
static int count_lines(const char *path, const char *needle) {
FILE *f = fopen(path, "r");
if (f == NULL) {
return -1;
}
int count = 0;
char line[1024];
while (fgets(line, sizeof line, f)) {
if (strstr(line, needle)) {
count++;
}
}
fclose(f);
return count;
}
// Daemon-style cleanup: close every descriptor the process does not know
// about, then open an application file. The guard's event must land in the
// event log, not in that file.
static int fd_reuse(const char *log_path, const char *app_path) {
for (int fd = 3; fd < getdtablesize(); fd++) {
close(fd);
}
int app_fd = open(app_path, O_CREAT | O_TRUNC | O_WRONLY, 0600);
if (app_fd < 0 || !refused_sendto(9)) {
return 2;
}
close(app_fd);
if (file_size(app_path) != 0) {
printf("FAIL fd-reuse: %lld bytes written into an unrelated file\n",
(long long)file_size(app_path));
return 1;
}
if (count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") != 1) {
printf("FAIL fd-reuse: event missing from the event log\n");
return 1;
}
return 0;
}
// Started with stdout closed, as the runner arranges: the process's own stdout
// output must not end up in the event log, and the event must.
static int low_fd(const char *log_path) {
printf("STDOUT-LINE\n");
fflush(stdout);
if (!refused_sendto(9)) {
return 2;
}
if (count_lines(log_path, "STDOUT-LINE") != 0) {
return 1;
}
return count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") == 1 ? 0 : 1;
}
static int nocancel(void) {
connect_fn nc_connect = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
sendto_fn nc_sendto = (sendto_fn)dlsym(RTLD_DEFAULT, "sendto$NOCANCEL");
sendmsg_fn nc_sendmsg = (sendmsg_fn)dlsym(RTLD_DEFAULT, "sendmsg$NOCANCEL");
if (!nc_connect || !nc_sendto || !nc_sendmsg) {
return 2;
}
struct sockaddr_in to = remote(443);
int failures = 0;
errno = 0;
if (nc_connect(-1, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
printf("FAIL nocancel: connect$NOCANCEL not refused (errno %d)\n", errno);
failures++;
}
errno = 0;
if (nc_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
printf("FAIL nocancel: sendto$NOCANCEL not refused (errno %d)\n", errno);
failures++;
}
char payload[] = "x";
struct iovec iov = {.iov_base = payload, .iov_len = 1};
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
errno = 0;
if (nc_sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
printf("FAIL nocancel: sendmsg$NOCANCEL not refused (errno %d)\n", errno);
failures++;
}
return failures ? 1 : 0;
}
// The shapes the kernel accepts as IPv4/IPv6 despite sa_family == AF_UNSPEC.
static int unspec(void) {
int failures = 0;
struct sockaddr_in to = remote(443);
to.sin_family = AF_UNSPEC;
int tcp = socket(AF_INET, SOCK_STREAM, 0);
errno = 0;
if (connect(tcp, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
printf("FAIL unspec: TCP connect with AF_UNSPEC not refused (errno %d)\n", errno);
failures++;
}
close(tcp);
struct sockaddr_in6 to6 = remote6(443);
to6.sin6_family = AF_UNSPEC;
int tcp6 = socket(AF_INET6, SOCK_STREAM, 0);
errno = 0;
if (connect(tcp6, (struct sockaddr *)&to6, sizeof to6) != -1 || errno != ECONNREFUSED) {
printf("FAIL unspec: TCP6 connect with AF_UNSPEC not refused (errno %d)\n", errno);
failures++;
}
close(tcp6);
errno = 0;
if (sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
printf("FAIL unspec: sendto with AF_UNSPEC not refused (errno %d)\n", errno);
failures++;
}
char payload[] = "x";
struct iovec iov = {.iov_base = payload, .iov_len = 1};
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
errno = 0;
if (sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
printf("FAIL unspec: sendmsg with AF_UNSPEC not refused (errno %d)\n", errno);
failures++;
}
return failures ? 1 : 0;
}
// A UDP socket associated with loopback dissolves the association through
// connect(AF_UNSPEC), whatever address bytes follow the family. The guard
// must let the kernel do that.
static int udp_disconnect(void) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in loopback;
memset(&loopback, 0, sizeof loopback);
loopback.sin_family = AF_INET;
loopback.sin_len = sizeof loopback;
loopback.sin_port = htons(9);
loopback.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
return 2;
}
struct sockaddr_in peer;
socklen_t peer_len = sizeof peer;
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) != 0) {
return 2;
}
int failures = 0;
for (int stale = 0; stale < 2; stale++) {
struct sockaddr_in dissolve = stale ? remote(443) : loopback;
if (!stale) {
memset(&dissolve, 0, sizeof dissolve);
dissolve.sin_len = sizeof dissolve;
}
dissolve.sin_family = AF_UNSPEC;
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
return 2;
}
errno = 0;
int rc = connect(fd, (struct sockaddr *)&dissolve, sizeof dissolve);
int err = errno;
peer_len = sizeof peer;
int still_connected = getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0;
if (rc != -1 || err != EAFNOSUPPORT || still_connected) {
printf("FAIL udp-disconnect (%s address): rc=%d errno=%d still_connected=%d\n",
stale ? "stale remote" : "zeroed", rc, err, still_connected);
failures++;
}
}
close(fd);
return failures ? 1 : 0;
}
// Past 128 distinct destinations a process writes one overflow line and
// nothing more; refusal continues.
static int overflow(const char *log_path) {
for (int port = 1; port <= 140; port++) {
if (!refused_sendto(port)) {
return 2;
}
}
int listed = count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:");
int overflow_lines = count_lines(log_path, "\toverflow\tblocked\t128 distinct destinations\t");
if (listed != 128 || overflow_lines != 1) {
printf("FAIL overflow: %d destinations listed, %d overflow line(s)\n", listed, overflow_lines);
return 1;
}
return 0;
}
// The event names the images above the guard, not the guard itself.
static int callers(const char *log_path) {
if (!refused_sendto(9)) {
return 2;
}
FILE *f = fopen(log_path, "r");
if (f == NULL) {
return 2;
}
char line[1024];
int ok = 0;
while (fgets(line, sizeof line, f)) {
if (strstr(line, "\tsendto\tblocked\t192.0.2.1:9\t")) {
const char *callers_field = strrchr(line, '\t') + 1;
ok = strstr(callers_field, "guard-insert-test") != NULL &&
strstr(callers_field, "egress-guard") == NULL;
if (!ok) {
printf("FAIL callers: %s", line);
}
}
}
fclose(f);
return ok ? 0 : 1;
}
int main(int argc, char **argv) {
if (argc < 3) {
return 2;
}
const char *name = argv[1];
const char *log_path = argv[2];
if (strcmp(name, "fd-reuse") == 0) {
return argc == 4 ? fd_reuse(log_path, argv[3]) : 2;
}
if (strcmp(name, "low-fd") == 0) {
return low_fd(log_path);
}
if (strcmp(name, "nocancel") == 0) {
return nocancel();
}
if (strcmp(name, "unspec") == 0) {
return unspec();
}
if (strcmp(name, "udp-disconnect") == 0) {
return udp_disconnect();
}
if (strcmp(name, "callers") == 0) {
return callers(log_path);
}
if (strcmp(name, "overflow") == 0) {
return overflow(log_path);
}
return 2;
}
@@ -0,0 +1,177 @@
// White-box host tests of guard.c's dedupe lock: the schedules that would
// abort the process are forced deterministically. Every case runs in its own
// process; sockets are never opened (fd -1), so nothing can leave the host.
// Build and run with tests/run-guard-tests.sh.
#include "../policy.h"
#include <pthread.h>
#include <signal.h>
#include <sys/wait.h>
static int scheduled_seen_insert(eg_seen_t *seen, const char *key);
#define eg_seen_insert scheduled_seen_insert
#include "../guard.c"
#undef eg_seen_insert
#include <arpa/inet.h>
#define CONTENTION_THREADS 8
#define CONTENTION_KEYS 12
#define CONTENTION_ROUNDS 25
static int interrupt_insert;
static int ready_pipe[2];
static int release_pipe[2];
static struct sockaddr_in remote(int port) {
struct sockaddr_in a;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_len = sizeof a;
a.sin_port = htons(port);
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
return a;
}
// Block mode must refuse before touching the kernel, even on an invalid fd.
static void send_remote(int port) {
struct sockaddr_in to = remote(port);
if (eg_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
_exit(2);
}
}
static void on_signal(int sig) {
(void)sig;
send_remote(9);
}
static int scheduled_seen_insert(eg_seen_t *seen, const char *key) {
if (interrupt_insert) {
interrupt_insert = 0;
raise(SIGUSR1);
}
return eg_seen_insert(seen, key);
}
static int signal_reentry(void) {
signal(SIGUSR1, on_signal);
interrupt_insert = 1;
send_remote(9);
send_remote(10);
return 0;
}
static void *hold_lock(void *unused) {
(void)unused;
char byte = 'x';
os_unfair_lock_lock(&eg_lock);
(void)write(ready_pipe[1], &byte, 1);
(void)read(release_pipe[0], &byte, 1);
os_unfair_lock_unlock(&eg_lock);
return NULL;
}
static int fork_while_locked(void) {
if (pipe(ready_pipe) || pipe(release_pipe)) {
return 2;
}
pthread_t thread;
if (pthread_create(&thread, NULL, hold_lock, NULL)) {
return 2;
}
char byte;
if (read(ready_pipe[0], &byte, 1) != 1) {
return 2;
}
pid_t child = fork();
if (child < 0) {
return 2;
}
if (child == 0) {
alarm(5);
send_remote(9);
send_remote(10);
_exit(0);
}
(void)write(release_pipe[1], "x", 1);
pthread_join(thread, NULL);
int status;
waitpid(child, &status, 0);
if (WIFSIGNALED(status)) {
printf("FAIL fork: child terminated by signal %d\n", WTERMSIG(status));
return 1;
}
return WIFEXITED(status) ? WEXITSTATUS(status) : 2;
}
static void *hammer(void *unused) {
(void)unused;
for (int round = 0; round < CONTENTION_ROUNDS; round++) {
for (int key = 0; key < CONTENTION_KEYS; key++) {
send_remote(1000 + key);
}
}
return NULL;
}
// Under contention a thread may give up its insert; the next attempt on the
// same key must then record it, so every key ends up logged exactly once.
static int contention(const char *log_path) {
pthread_t threads[CONTENTION_THREADS];
for (int i = 0; i < CONTENTION_THREADS; i++) {
if (pthread_create(&threads[i], NULL, hammer, NULL)) {
return 2;
}
}
for (int i = 0; i < CONTENTION_THREADS; i++) {
pthread_join(threads[i], NULL);
}
FILE *log = fopen(log_path, "r");
if (log == NULL) {
return 2;
}
int counts[CONTENTION_KEYS] = {0};
int total = 0;
char line[1024];
while (fgets(line, sizeof line, log)) {
total++;
for (int key = 0; key < CONTENTION_KEYS; key++) {
char needle[64];
snprintf(needle, sizeof needle, "\tsendto\tblocked\t192.0.2.1:%d\t", 1000 + key);
if (strstr(line, needle)) {
counts[key]++;
}
}
}
fclose(log);
int failures = 0;
for (int key = 0; key < CONTENTION_KEYS; key++) {
if (counts[key] != 1) {
printf("FAIL contention: 192.0.2.1:%d logged %d times\n", 1000 + key, counts[key]);
failures++;
}
}
if (total != CONTENTION_KEYS) {
printf("FAIL contention: %d lines for %d keys\n", total, CONTENTION_KEYS);
failures++;
}
return failures ? 1 : 0;
}
int main(int argc, char **argv) {
if (argc != 2) {
return 2;
}
eg_mode = EG_MODE_BLOCK;
if (strcmp(argv[1], "signal") == 0) {
return signal_reentry();
}
if (strcmp(argv[1], "fork") == 0) {
return fork_while_locked();
}
if (strcmp(argv[1], "contention") == 0) {
return contention(eg_log_path);
}
return 2;
}
@@ -0,0 +1,172 @@
// Probe run inside a simulator by the egress guard end-to-end test. Exercises
// every outbound path the guard must cover and prints one JSON object.
//
// nettest --proxy-port <port> --udp-port <port>
//
// proxy-port: a CONNECT proxy on the host's loopback, standing in for the
// egress proxy. udp-port: a UDP echo on loopback.
import Foundation
import Network
var results: [String: String] = [:]
let args = CommandLine.arguments
func arg(_ name: String) -> Int {
guard let i = args.firstIndex(of: name), i + 1 < args.count else { return 0 }
return Int(args[i + 1]) ?? 0
}
let proxyPort = arg("--proxy-port")
let udpPort = arg("--udp-port")
let remoteHost = "example.com"
let remotePort = 443
func classify(_ error: Error?) -> String {
guard let e = error as NSError? else { return "ok" }
if e.domain == NSURLErrorDomain && e.code == NSURLErrorCannotConnectToHost { return "refused" }
if e.domain == NSPOSIXErrorDomain && e.code == Int(ECONNREFUSED) { return "refused" }
return "error(\(e.domain):\(e.code))"
}
// 1. URLSession straight to the internet. Blocked by the guard.
func urlSessionDirect() {
let g = DispatchGroup(); g.enter()
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 10
c.connectionProxyDictionary = [:] // explicitly no proxy, even if the host has one
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
results["urlsessionDirect"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
}.resume(); g.wait()
}
// 2. URLSession through a CONNECT proxy on loopback. Allowed: the guard only
// sees a loopback connect.
func urlSessionProxied() {
let g = DispatchGroup(); g.enter()
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 15
// The kCFNetworkProxies* constants are not exposed to Swift on iOS; these are their values.
c.connectionProxyDictionary = ["HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": proxyPort]
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
results["urlsessionProxied"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
}.resume(); g.wait()
}
// 3. Network.framework connection straight out. Blocked.
func nwConnectionDirect() {
let g = DispatchGroup(); g.enter()
let p = NWParameters.tls; p.preferNoProxies = true
let c = NWConnection(host: NWEndpoint.Host(remoteHost), port: NWEndpoint.Port(integerLiteral: UInt16(remotePort)), using: p)
var done = false
c.stateUpdateHandler = { s in
guard !done else { return }
switch s {
case .ready: done = true; results["nwconnectionDirect"] = "ok"; c.cancel(); g.leave()
case .failed(let e): done = true; results["nwconnectionDirect"] = classify(e); g.leave()
case .waiting(let e): done = true; results["nwconnectionDirect"] = "waiting(" + classify(e) + ")"; c.cancel(); g.leave()
default: break
}
}
c.start(queue: .global())
_ = g.wait(timeout: .now() + 15)
if results["nwconnectionDirect"] == nil { results["nwconnectionDirect"] = "timeout"; c.cancel() }
}
// 4. Plain BSD TCP connect to a resolved address. Blocked.
func bsdConnectDirect() {
var hints = addrinfo(); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET
var info: UnsafeMutablePointer<addrinfo>? = nil
guard getaddrinfo(remoteHost, "443", &hints, &info) == 0, let ai = info else { results["bsdConnectDirect"] = "dns-failed"; return }
results["dns"] = "ok"
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
let rc = connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
results["bsdConnectDirect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
close(fd); freeaddrinfo(info)
}
// 5. UDP datagram straight out. Blocked. 6. UDP datagram to loopback echo. Allowed.
func udp(_ key: String, _ host: String, _ port: Int, expectEcho: Bool) {
let fd = socket(AF_INET, SOCK_DGRAM, 0)
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(port)).bigEndian
inet_pton(AF_INET, host, &to.sin_addr)
var payload: [UInt8] = Array("ping".utf8)
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
sendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
if sent < 0 { results[key] = errno == ECONNREFUSED ? "refused" : "errno(\(errno))"; close(fd); return }
if expectEcho {
var tv = timeval(tv_sec: 3, tv_usec: 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var buf = [UInt8](repeating: 0, count: 16)
let n = recv(fd, &buf, buf.count, 0)
results[key] = n > 0 ? "ok" : "no-echo"
} else { results[key] = "sent" }
close(fd)
}
urlSessionDirect()
urlSessionProxied()
nwConnectionDirect()
bsdConnectDirect()
udp("udpDirect", "8.8.8.8", 53, expectEcho: false)
udp("udpLoopback", "127.0.0.1", udpPort, expectEcho: true)
// 7. The same literal destination twice: the guard must log it once per process.
func bsdConnectLiteral(_ key: String) {
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(443)).bigEndian
inet_pton(AF_INET, "1.1.1.1", &to.sin_addr)
let fd = socket(AF_INET, SOCK_STREAM, 0)
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
results[key] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
close(fd)
}
bsdConnectLiteral("literalFirst")
bsdConnectLiteral("literalSecond")
// 8. A sockaddr_in whose family was left AF_UNSPEC: the kernel connects it as
// IPv4, so the guard must refuse it. 9. connect(AF_UNSPEC) on a UDP socket
// dissolves the association; the guard must leave that to the kernel.
func unspecConnect() {
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_UNSPEC); to.sin_port = in_port_t(UInt16(443)).bigEndian
inet_pton(AF_INET, "1.0.0.1", &to.sin_addr)
let fd = socket(AF_INET, SOCK_STREAM, 0)
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
results["unspecConnect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
close(fd)
}
func udpDisconnect() {
let fd = socket(AF_INET, SOCK_DGRAM, 0)
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(udpPort)).bigEndian
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr)
let associated = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
var dissolve = sockaddr_in(); dissolve.sin_family = sa_family_t(AF_UNSPEC); dissolve.sin_port = in_port_t(UInt16(443)).bigEndian
inet_pton(AF_INET, "1.1.1.1", &dissolve.sin_addr)
let rc = withUnsafePointer(to: &dissolve) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
let err = errno
var peer = sockaddr_in(); var peerLength = socklen_t(MemoryLayout<sockaddr_in>.size)
let stillConnected = withUnsafeMutablePointer(to: &peer) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
getpeername(fd, sa, &peerLength) } } == 0
results["udpDisconnect"] = associated != 0 ? "associate-failed"
: (rc == -1 && err == EAFNOSUPPORT && !stillConnected) ? "ok" : "errno(\(err)),connected=\(stillConnected)"
close(fd)
}
// 10. The non-cancelable variant of sendto, as libsystem exports it. Blocked.
func nocancelSendto() {
typealias SendtoFn = @convention(c) (Int32, UnsafeRawPointer?, Int, Int32, UnsafePointer<sockaddr>?, socklen_t) -> Int
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sendto$NOCANCEL") else {
results["nocancelSendto"] = "no-symbol"; return
}
let nocancelSendto = unsafeBitCast(symbol, to: SendtoFn.self)
let fd = socket(AF_INET, SOCK_DGRAM, 0)
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(53)).bigEndian
inet_pton(AF_INET, "8.8.8.8", &to.sin_addr)
var payload: [UInt8] = Array("ping".utf8)
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
nocancelSendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
results["nocancelSendto"] = sent < 0 ? (errno == ECONNREFUSED ? "refused" : "errno(\(errno))") : "sent"
close(fd)
}
unspecConnect()
udpDisconnect()
nocancelSendto()
let json = try! JSONSerialization.data(withJSONObject: results, options: [.sortedKeys])
print(String(data: json, encoding: .utf8)!)
@@ -0,0 +1,143 @@
// Host-runnable tests for the guard's policy and formatting. Build and run
// with tests/run-policy-tests.sh; every failure prints the failing line.
#include "../policy.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/un.h>
static int failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
failures++; \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
static struct sockaddr_in v4(const char *ip, int port) {
struct sockaddr_in a; memset(&a, 0, sizeof a);
a.sin_family = AF_INET; a.sin_len = sizeof a; a.sin_port = htons(port);
inet_pton(AF_INET, ip, &a.sin_addr);
return a;
}
static struct sockaddr_in6 v6(const char *ip, int port) {
struct sockaddr_in6 a; memset(&a, 0, sizeof a);
a.sin6_family = AF_INET6; a.sin6_len = sizeof a; a.sin6_port = htons(port);
inet_pton(AF_INET6, ip, &a.sin6_addr);
return a;
}
static void test_classify(void) {
struct sockaddr_in a4; struct sockaddr_in6 a6; struct sockaddr_un un;
a4 = v4("127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
a4 = v4("127.42.0.9", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
a4 = v4("0.0.0.0", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
a4 = v4("192.168.1.10", 8081); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
a4 = v4("169.254.1.1", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
a4 = v4("224.0.0.251", 5353); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
a6 = v6("::1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
a6 = v6("::", 80); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
a6 = v6("::ffff:127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
a6 = v6("::ffff:93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
a6 = v6("2606:4700::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
a6 = v6("fe80::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
memset(&un, 0, sizeof un); un.sun_family = AF_UNIX; strcpy(un.sun_path, "/var/run/mDNSResponder");
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
CHECK(eg_classify(NULL, 0) == EG_PASSTHROUGH);
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, 4) == EG_PASSTHROUGH); // too short to read
// AF_UNSPEC is what the kernel makes of it: the family the exact length implies.
a4 = v4("93.184.216.34", 443); a4.sin_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4 + 8) == EG_PASSTHROUGH); // kernel: EINVAL
a4 = v4("127.0.0.1", 8899); a4.sin_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
a4 = v4("0.0.0.0", 0); a4.sin_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK); // UDP disconnect shape
a6 = v6("2606:4700::1", 443); a6.sin6_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
a6 = v6("::ffff:93.184.216.34", 443); a6.sin6_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
a6 = v6("::1", 443); a6.sin6_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
memset(&un, 0, sizeof un); un.sun_family = AF_UNSPEC;
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
}
static void test_mode(void) {
CHECK(eg_parse_mode(NULL) == EG_MODE_BLOCK);
CHECK(eg_parse_mode("") == EG_MODE_BLOCK);
CHECK(eg_parse_mode("block") == EG_MODE_BLOCK);
CHECK(eg_parse_mode("log") == EG_MODE_LOG);
CHECK(eg_parse_mode("LOG") == EG_MODE_BLOCK);
CHECK(eg_parse_mode("permissive") == EG_MODE_BLOCK);
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_REMOTE) == 1);
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_LOOPBACK) == 0);
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_PASSTHROUGH) == 0);
CHECK(eg_should_deny(EG_MODE_LOG, EG_REMOTE) == 0);
}
static void test_format_peer(void) {
char out[64];
struct sockaddr_in a4 = v4("93.184.216.34", 443);
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
CHECK(strcmp(out, "93.184.216.34:443") == 0);
struct sockaddr_in6 a6 = v6("2606:4700::1", 443);
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, 8) != 0);
struct sockaddr_un un; memset(&un, 0, sizeof un); un.sun_family = AF_UNIX;
CHECK(eg_format_peer((struct sockaddr *)&un, sizeof un, out, sizeof out) != 0);
a4.sin_family = AF_UNSPEC;
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
CHECK(strcmp(out, "93.184.216.34:443") == 0);
a6.sin6_family = AF_UNSPEC;
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
}
static void test_seen(void) {
static eg_seen_t seen; memset(&seen, 0, sizeof seen);
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 1);
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 0);
CHECK(eg_seen_insert(&seen, "sendto 1.1.1.1:443") == 1);
CHECK(seen.count == 2);
char key[64];
for (int i = 0; i < EG_SEEN_CAPACITY + 5; i++) { snprintf(key, sizeof key, "k%d", i); eg_seen_insert(&seen, key); }
CHECK(seen.count == EG_SEEN_CAPACITY);
CHECK(seen.overflow == 7); // 2 + 128 + 5 attempts, 128 fit
CHECK(eg_seen_insert(&seen, "k0") == 0); // still remembered
}
static void test_format_event(void) {
char out[512];
const char *callers[] = {"Network", "CFNetwork", "MyApp"};
int n = eg_format_event(out, sizeof out, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3);
CHECK(n > 0);
CHECK(strcmp(out, "eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp\n") == 0);
const char *tabbed[] = {"a\tb"};
n = eg_format_event(out, sizeof out, "we\nird\tname", 1, "sendto", "logged", "1.2.3.4:53", tabbed, 1);
CHECK(n > 0);
CHECK(strcmp(out, "eas-egress-guard\twe ird name\t1\tsendto\tlogged\t1.2.3.4:53\ta b\n") == 0);
n = eg_format_event(out, sizeof out, "NoCallers", 7, "connectx", "blocked", "[::2]:80", NULL, 0);
CHECK(n > 0 && strcmp(out, "eas-egress-guard\tNoCallers\t7\tconnectx\tblocked\t[::2]:80\t\n") == 0);
CHECK(eg_format_event(out, 16, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3) == -1);
}
int main(void) {
test_classify(); test_mode(); test_format_peer(); test_seen(); test_format_event();
if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; }
printf("egress-guard policy tests: all passed\n");
return 0;
}
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Builds the guard for the host (macOS) and runs the guard tests against it:
# white-box lock schedules with guard.c compiled in, and black-box cases with
# the built library inserted the way the simulator inserts it. No socket is
# ever opened to a non-loopback address.
set -euo pipefail
cd "$(dirname "$0")/.."
out=$(mktemp -d)
trap 'rm -rf "$out"' EXIT
clang -std=c11 -Wall -Wextra -Werror -O2 -dynamiclib policy.c guard.c -o "$out/egress-guard.dylib"
clang -std=c11 -Wall -Wextra -Werror -O2 -pthread policy.c tests/guard_test.c -o "$out/guard-test"
clang -std=c11 -Wall -Wextra -Werror -O2 tests/guard_insert_test.c -o "$out/guard-insert-test"
failures=0
report() {
if [[ $2 -eq 0 ]]; then
echo "ok $1"
else
echo "FAIL $1 (exit $2)"
failures=$((failures + 1))
fi
}
# White-box: guard.c compiled into the test, lock schedules forced.
for name in signal fork contention; do
status=0
: > "$out/$name.log"
EAS_EGRESS_GUARD_MODE=block EAS_EGRESS_GUARD_LOG="$out/$name.log" \
"$out/guard-test" "$name" || status=$?
report "$name" "$status"
done
# Black-box: the built library inserted into a plain process.
export DYLD_INSERT_LIBRARIES="$out/egress-guard.dylib"
export EAS_EGRESS_GUARD_MODE=block
status=0
: > "$out/fd-reuse.log"
EAS_EGRESS_GUARD_LOG="$out/fd-reuse.log" \
"$out/guard-insert-test" fd-reuse "$out/fd-reuse.log" "$out/application-file" || status=$?
report fd-reuse "$status"
status=0
: > "$out/low-fd.log"
EAS_EGRESS_GUARD_LOG="$out/low-fd.log" \
"$out/guard-insert-test" low-fd "$out/low-fd.log" 1>&- || status=$?
report low-fd "$status"
for name in nocancel unspec udp-disconnect callers overflow; do
status=0
: > "$out/$name.log"
EAS_EGRESS_GUARD_LOG="$out/$name.log" \
"$out/guard-insert-test" "$name" "$out/$name.log" || status=$?
report "$name" "$status"
done
if [[ $failures -ne 0 ]]; then
echo "egress-guard guard tests: $failures failure(s)"
exit 1
fi
echo "egress-guard guard tests: all passed"
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Builds the policy unit tests for the host (macOS) and runs them.
set -euo pipefail
cd "$(dirname "$0")/.."
out=$(mktemp -d)
clang -std=c11 -Wall -Wextra -Werror -o "$out/policy_test" policy.c tests/policy_test.c
"$out/policy_test"
@@ -0,0 +1,130 @@
// Regenerates src/steps/utils/appiumCommands.generated.ts from Appium and the
// XCUITest and UiAutomator2 drivers, so TypeScript can verify that we handle
// every command that can appear in Appium event timings.
//
// Appium and its drivers are NOT dependencies of this package. Like repack, we
// install them on demand into a temp directory just for this script, so nothing
// heavy ships with @expo/build-tools.
//
// Run with: yarn workspace @expo/build-tools generate-appium-commands
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
// Track the Appium 3 line, matching the appium@^3 the worker installs for
// simulator sessions (see startAppiumRemoteSession.ts).
const APPIUM_VERSION = '^3';
const DRIVERS = [
{
packageName: 'appium-xcuitest-driver',
exportName: 'XCUITestDriver',
typeName: 'XCUITestAppiumCommand',
},
{
packageName: 'appium-uiautomator2-driver',
exportName: 'AndroidUiautomator2Driver',
typeName: 'UiAutomator2AppiumCommand',
},
];
function renderCommandType(typeName, commands) {
return `export type ${typeName} =\n${commands.map(command => ` | ${serializeStringLiteral(command)}`).join('\n')};`;
}
function serializeStringLiteral(value) {
const jsonContents = JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"');
return `'${jsonContents.replaceAll("'", "\\'")}'`;
}
function readRouteCommands(methodMap) {
return Object.values(methodMap).flatMap(route =>
Object.values(route).map(method => method.command)
);
}
async function readDriverCommands({ packageName, exportName }, sandbox) {
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [sandbox] });
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const modulePath = path.join(path.dirname(packageJsonPath), packageJson.exports['.'].import);
const driverModule = await import(pathToFileURL(modulePath).href);
const Driver = driverModule[exportName];
return [
...readRouteCommands(Driver.newMethodMap),
...Object.values(Driver.executeMethodMap).map(method => method.command),
];
}
async function main() {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'appium-command-generator-'));
try {
fs.writeFileSync(
path.join(sandbox, 'package.json'),
`${JSON.stringify({ name: 'appium-command-generator-sandbox', private: true })}\n`
);
execFileSync(
'npm',
[
'install',
'--prefix',
sandbox,
`appium@${APPIUM_VERSION}`,
...DRIVERS.map(({ packageName }) => packageName),
],
{ stdio: 'inherit' }
);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ALL_COMMANDS } = require(require.resolve('@appium/base-driver', { paths: [sandbox] }));
const baseDriverCommands = [...new Set(ALL_COMMANDS)].sort();
const baseDriverCommandSet = new Set(baseDriverCommands);
const driverCommands = await Promise.all(
DRIVERS.map(async driver => ({
typeName: driver.typeName,
commands: [...new Set(await readDriverCommands(driver, sandbox))]
.filter(command => !baseDriverCommandSet.has(command))
.sort(),
}))
);
const commands = [
...new Set([...baseDriverCommands, ...driverCommands.flatMap(({ commands }) => commands)]),
];
const outputPath = path.join(
__dirname,
'..',
'src',
'steps',
'utils',
'appiumCommands.generated.ts'
);
const contents = `// AUTO-GENERATED. Do not edit by hand.
// Run \`yarn workspace @expo/build-tools generate-appium-commands\` to refresh this
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
${renderCommandType('BaseDriverAppiumCommand', baseDriverCommands)}
${driverCommands
.map(({ typeName, commands: commandsForDriver }) =>
renderCommandType(typeName, commandsForDriver)
)
.join('\n\n')}
export type AppiumCommand =
| BaseDriverAppiumCommand
| XCUITestAppiumCommand
| UiAutomator2AppiumCommand;
`;
fs.writeFileSync(outputPath, contents);
// eslint-disable-next-line no-console
console.log(`Wrote ${commands.length} Appium commands to ${outputPath}`);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
}
main().catch(error => {
// eslint-disable-next-line no-console
console.error(error);
process.exitCode = 1;
});
@@ -1,4 +1,4 @@
import bunyan from 'bunyan';
import type { bunyan } from '@expo/logger';
export function createLogger(): bunyan {
const logger = {
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('copies the MCP server URL from the build context', () => {
const ctx = new BuildContext(createTestIosJob(), {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
mcpServerUrl: 'ws://localhost:8787',
});
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
});
it('should not lose workflowInterpolationContext', () => {
const contextUploadArtifact = jest.fn();
const ctx = new BuildContext(
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it.each(['success', 'failure'])(
'releases local egress after workflow %s, before uploading outputs',
async outcome => {
const executeAsync =
outcome === 'success'
? jest.fn().mockResolvedValue(undefined)
: jest.fn().mockRejectedValue(new Error(outcome));
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
}));
const { runResult } = await runGenericJobAsync(mockCtx);
expect(runResult.ok).toBe(outcome === 'success');
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
);
}
);
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
@@ -60,6 +60,7 @@ jest.mock('../../utils/expoUpdates', () => ({
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
@@ -317,36 +318,82 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
describe('embedded bundle upload phase', () => {
function createContext({
env = {},
sdkVersion,
}: {
env?: Record<string, string>;
sdkVersion?: string;
}): BuildContext<Android.Job> {
return new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
...env,
},
uploadArtifact: jest.fn(),
metadata: sdkVersion ? { sdkVersion } : undefined,
});
}
it('runs by default on SDK 58 and later', async () => {
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
await androidBuilder(ctx);
it('skips by default on SDK 57 and earlier', async () => {
const ctx = createContext({ sdkVersion: '57.0.0' });
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
await androidBuilder(ctx);
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
await androidBuilder(ctx);
it('skips by default when the SDK version is unknown', async () => {
const ctx = createContext({});
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
const ctx = createContext({
sdkVersion: '58.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
});
});
+7 -4
View File
@@ -32,7 +32,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -98,7 +101,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform },
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -223,7 +226,7 @@ async function buildInnerAsync(
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
@@ -238,7 +241,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform },
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
+25 -6
View File
@@ -18,14 +18,21 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreCocoapodsCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -103,7 +110,13 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreCocoapodsCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -230,7 +243,7 @@ async function buildInnerAsync(
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
@@ -245,11 +258,17 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveCocoapodsCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
expect(spawn).not.toHaveBeenCalled();
});
it('overrides the minimum release age only for the internal config command', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {
FOO: 'bar',
NPM_CONFIG_MIN_RELEASE_AGE: '365',
},
job: { platform: 'ios', buildProfile: 'production' },
} as any;
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
jest.mocked(spawn).mockResolvedValueOnce({
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
} as any);
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.any(Array),
expect.objectContaining({
env: {
FOO: 'bar',
NPM_CONFIG_MIN_RELEASE_AGE: '0',
},
})
);
expect(ctx.env).toEqual({
FOO: 'bar',
NPM_CONFIG_MIN_RELEASE_AGE: '365',
});
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
import {
installDependenciesAsync,
installDependenciesWithNpmCacheFallbackAsync,
} from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
});
});
describe(installDependenciesAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
});
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.PNPM,
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['install', '--filter', 'my-app', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.PNPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['install', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.BUN,
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--filter', 'my-app', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
const logger = createMockLogger();
await installDependenciesAsync({
packageManager: PackageManager.BUN,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: true,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--frozen-lockfile'],
expect.objectContaining({ cwd: '/tmp/build' })
);
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
@@ -2,10 +2,13 @@ import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
ErrorCode,
Job,
Platform,
SystemError,
Workflow,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
@@ -14,7 +17,7 @@ import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { shallowCloneRepositoryAsync } from '../git';
import { prepareProjectSourcesAsync } from '../projectSources';
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
jest.mock('@expo/turtle-spawn');
jest.mock('node-fetch');
@@ -23,7 +26,28 @@ jest.mock('@expo/downloader');
jest.mock('@urql/core');
describe('projectSources', () => {
it('should use the refreshed repository URL', async () => {
it('makes extracted project sources readable and writable by the worker', async () => {
const logger = createMockLogger();
await unpackTarGzAsync({
source: '/workingdir/project.tar.gz',
destination: '/workingdir/build',
logger,
});
expect(spawn).toHaveBeenNthCalledWith(
1,
'tar',
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
{ logger }
);
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
logger,
});
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
});
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
@@ -39,7 +63,9 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
...(includeRepositoryUrl
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
: {}),
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -89,102 +115,88 @@ describe('projectSources', () => {
);
});
it('should fallback to the original repository URL if the refresh fails', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
'throws a system error if refresh fails (%s)',
async failure => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
const cause = new Error('Source request failed');
fetchMock.mockImplementation(async () => {
if (failure === 'network') {
throw cause;
}
return {
ok: failure !== 'http',
status: failure === 'http' ? 500 : 200,
json: async () => {
if (failure === 'json') {
throw cause;
}
if (['PATH', 'GCS', 'R2'].includes(failure)) {
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
}
if (failure === 'missing-url') {
return {
data: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
};
}
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
},
} as Response;
});
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
errorCode: ErrorCode.SERVER_ERROR,
cause: expect.any(Error),
});
if (failure === 'network') {
await expect(result).rejects.toMatchObject({ cause });
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: false,
text: async () => 'Failed to generate repository URL',
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: async () => ({
// repositoryUrl is the right key
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: () => Promise.reject(new Error('Failed to generate repository URL')),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
}, 15_000);
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
},
15_000
);
it('should retry fetching the repository URL', async () => {
const robotAccessToken = randomUUID();
@@ -202,7 +214,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
},
@@ -259,7 +270,7 @@ describe('projectSources', () => {
);
}, 15_000);
it(`should fallback to the original repository URL if we're missing some config`, async () => {
it(`throws a system error if refresh configuration is missing`, async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const logger = createMockLogger();
@@ -273,7 +284,6 @@ describe('projectSources', () => {
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
@@ -297,12 +307,11 @@ describe('projectSources', () => {
}
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Failed to refresh project archive, falling back to the original one'
);
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
errorCode: ErrorCode.SERVER_ERROR,
cause: new Error('EAS_BUILD_ID is not set'),
});
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
describe('uploadProjectMetadataAsFireAndForget', () => {
@@ -382,6 +391,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
// Call prepareProjectSourcesAsync and don't await metadata upload
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
@@ -495,6 +508,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
const startTime = Date.now();
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
@@ -559,6 +576,10 @@ describe('projectSources', () => {
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
} as Response);
// Should not throw even though upload will fail
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
@@ -573,3 +594,56 @@ describe('projectSources', () => {
});
});
});
describe('local project sources', () => {
function createContext(type: ArchiveSourceType): BuildContext<Job> {
return new BuildContext(
{
platform: Platform.IOS,
projectArchive: { type, path: '/source.tar.gz' },
} as Job,
{
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
it('copies and unpacks a PATH source without fetching from www', async () => {
await vol.promises.mkdir('/workingdir', { recursive: true });
await vol.promises.writeFile('/source.tar.gz', 'local archive');
const ctx = createContext(ArchiveSourceType.PATH);
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
handled: true,
});
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
expect(spawn).toHaveBeenCalledWith(
'tar',
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
{ logger: ctx.logger }
);
expect(fetch).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
it.each(
Object.values(ArchiveSourceType).filter(
type => type !== ArchiveSourceType.PATH && type !== ArchiveSourceType.NONE
)
)('rejects local %s sources before fetching or unpacking', async type => {
const ctx = createContext(type);
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
errorCode: 'SERVER_ERROR',
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
message: `Expected a PATH project source for a local build, received ${type}.`,
});
expect(fetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
destinationDirectory,
}: {
logger: bunyan;
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
destinationDirectory: string;
}): Promise<void> {
const { repositoryUrl } = archiveSource;
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
break;
}
case PackageManager.PNPM: {
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
args = [
'install',
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
];
break;
}
case PackageManager.YARN: {
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
break;
}
case PackageManager.BUN:
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
args = [
'install',
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
];
break;
default:
throw new Error(`Unsupported package manager: ${packageManager}`);
@@ -1,5 +1,5 @@
import downloadFile from '@expo/downloader';
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
@@ -19,33 +19,30 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
destinationDirectory: string
): // Return type required to make switch exhaustive.
Promise<{ handled: boolean }> {
let projectArchive: ArchiveSource = ctx.job.projectArchive;
if (ctx.isLocal) {
console.warn('Local build, skipping project archive refresh');
} else {
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
ctx.logger.error(
{ err: projectArchiveResult.reason },
'Failed to refresh project archive, falling back to the original one'
);
}
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
if (ctx.job.projectArchive.type === ArchiveSourceType.NONE) {
return { handled: true };
}
if (ctx.isLocal) {
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
throw new SystemError(
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
);
}
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
return { handled: true };
}
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
cause: projectArchiveResult.reason,
});
}
const projectArchive = projectArchiveResult.value;
switch (projectArchive.type) {
case ArchiveSourceType.R2:
case ArchiveSourceType.GCS: {
throw new Error('Remote project sources should be resolved earlier to URL');
}
case ArchiveSourceType.PATH: {
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
return { handled: true };
}
case ArchiveSourceType.NONE: {
// May be used in no-sources jobs like submission jobs.
return { handled: true };
@@ -110,7 +107,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
});
}
async function unpackTarGzAsync({
export async function unpackTarGzAsync({
logger,
source,
destination,
@@ -122,6 +119,8 @@ async function unpackTarGzAsync({
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
logger,
});
logger.info('Normalizing project source permissions');
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
}
function uploadProjectMetadataAsFireAndForget(
@@ -247,7 +246,7 @@ async function uploadProjectMetadataAsync(
}
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
@@ -285,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
);
}
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
const dataResult = z
.object({
data: z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]),
})
.safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
+3
View File
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
+12 -1
View File
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
import { CustomBuildContext } from './customBuildContext';
import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
}
});
const runResult = await asyncResult(workflow.executeAsync());
const runResult = await asyncResult(
(async () => {
try {
await workflow.executeAsync();
} finally {
// The session-owning step may never run if simulator boot/setup fails,
// or may time out while resource acquisition is still in flight.
await stopLocalEgressResourcesAsync(ctx.logger);
}
})()
);
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
const results = await Promise.allSettled([
+5
View File
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
export {
ArtifactToUpload,
@@ -18,6 +20,9 @@ export {
export { PackageManager } from './utils/packageManager';
export * as TurtleSshSession from './utils/turtleSshSession';
export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
@@ -0,0 +1,46 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { getXcodeVersionAsync } from '../xcode';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(getXcodeVersionAsync, () => {
it('returns the Xcode version', async () => {
mockedSpawn.mockResolvedValue({
stdout: 'Xcode 26.0\nBuild version 17A324\n',
} as any);
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
stdio: 'pipe',
env: process.env,
});
});
it('throws when the Xcode version cannot be parsed', async () => {
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
'Failed to determine Xcode version'
);
});
it('wraps errors from xcodebuild in a SystemError', async () => {
const cause = new Error('xcode-select: error: invalid developer directory');
mockedSpawn.mockRejectedValue(cause);
const promise = getXcodeVersionAsync({ env: process.env });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: 'Failed to get Xcode version',
cause,
});
});
});
@@ -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'
+16
View File
@@ -0,0 +1,16 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
let stdout: string;
try {
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
} catch (error) {
throw new SystemError('Failed to get Xcode version', { cause: error });
}
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
if (!version) {
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
}
return version;
}
+22 -2
View File
@@ -19,8 +19,11 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
@@ -43,17 +46,24 @@ import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -73,6 +83,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -93,17 +105,24 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartLocalEgressBuildFunction(),
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
@@ -118,6 +137,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('restores and saves caches around an iOS simulator build', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const stepNames = steps.map(step => step.displayName);
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
const installPodsIndex = stepNames.indexOf('Install Pods');
const saveCacheIndex = stepNames.indexOf('Save Cache');
expect(restoreCacheIndex).toBeGreaterThan(-1);
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
const restoreCacheStep = steps[restoreCacheIndex];
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
simulator: true,
},
}
);
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
},
});
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
simulator: true,
},
}
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
@@ -1,6 +1,6 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createInstallMaestroBuildFunction } from '../installMaestro';
import { createInstallMaestroBuildFunction, installIdbFromBrew } from '../installMaestro';
jest.unmock('fs');
jest.unmock('@expo/logger');
@@ -57,3 +57,14 @@ describe('createInstallMaestroBuildFunction', () => {
console.log(latestStep.outputs[0].value);
}, 180_000);
});
describe('installIdbFromBrew', () => {
it('installs 1.1.8 version', async () => {
const logger = createMockLogger({ logToConsole: true });
await installIdbFromBrew({
logger,
env: process.env,
});
});
});
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
const GIT_PROJECT_ARCHIVE = {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
};
@@ -0,0 +1,136 @@
import { spawnAsync } from '@expo/steps';
import { vol } from 'memfs';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import {
compressCocoapodsCacheAsync,
getCocoapodsCachePaths,
resolveCocoapodsCacheKeyAsync,
restoreCocoapodsCacheArchiveAsync,
} from '../../../utils/cocoapodsCache';
import { downloadCacheAsync } from '../restoreCache';
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
import { uploadCacheAsync } from '../saveCache';
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('../restoreCache', () => ({
decompressCacheAsync: jest.fn(),
downloadCacheAsync: jest.fn(),
downloadPublicCacheAsync: jest.fn(),
}));
jest.mock('../saveCache', () => ({
compressCacheAsync: jest.fn(),
uploadCacheAsync: jest.fn(),
}));
jest.mock('../../../utils/cocoapodsCache', () => ({
compressCocoapodsCacheAsync: jest.fn(),
getCocoapodsCachePaths: jest.fn(),
resolveCocoapodsCacheKeyAsync: jest.fn(),
restoreCocoapodsCacheArchiveAsync: jest.fn(),
}));
const logger = createMockLogger();
const env = {
EAS_PODS_CACHE: '1',
EAS_BUILD_ID: 'build-id',
__API_SERVER_URL: 'https://api.expo.test',
};
const secrets = { robotAccessToken: 'robot-token' };
describe('CocoaPods build cache', () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
'/workingdir/ios/Podfile.lock': 'lockfile',
'/tmp/cocoapods-cache.tar.gz': 'archive',
},
'/'
);
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
iosDirectory: '/workingdir/ios',
podsDirectory: '/workingdir/ios/Pods',
podfileLockPath: '/workingdir/ios/Podfile.lock',
});
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
key: 'ios-pods-1.16.2-lock-hash',
keyPrefix: 'ios-pods-1.16.2-',
});
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
});
it('does nothing when the cache is disabled', async () => {
await restoreCocoapodsCacheAsync({
logger,
workingDirectory: '/workingdir',
env: {},
secrets,
});
await saveCocoapodsCacheAsync({
logger,
workingDirectory: '/workingdir',
env: {},
secrets,
});
expect(spawnAsync).not.toHaveBeenCalled();
});
it('restores the newest matching CocoaPods cache', async () => {
jest.mocked(downloadCacheAsync).mockResolvedValue({
archivePath: '/tmp/cocoapods-cache.tar.gz',
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
});
const datadogLogSpy = jest.spyOn(Datadog, 'log');
await restoreCocoapodsCacheAsync({
logger,
workingDirectory: '/workingdir',
env,
secrets,
});
expect(downloadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
key: 'ios-pods-1.16.2-lock-hash',
keyPrefixes: ['ios-pods-1.16.2-'],
paths: ['/workingdir/ios/Pods'],
})
);
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
archivePath: '/tmp/cocoapods-cache.tar.gz',
workingDirectory: '/workingdir',
});
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
event: 'cocoapods_cache_restored',
cache_hit_type: 'prefix_match',
});
});
it('compresses and saves the installed Pods directory', async () => {
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
archivePath: '/tmp/cocoapods-cache.tar.gz',
});
await saveCocoapodsCacheAsync({
logger,
workingDirectory: '/workingdir',
env,
secrets,
});
expect(uploadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: '/tmp/cocoapods-cache.tar.gz',
key: 'ios-pods-1.16.2-lock-hash',
paths: ['/workingdir/ios/Pods'],
})
);
});
});
@@ -0,0 +1,245 @@
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(), child: jest.fn().mockReturnThis() };
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);
// An agent-device preview failure aborts the daemon task, which then may not start its
// daemon or tunnel. Everything that started must be stopped.
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
expect(stopTool).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
);
expect(stopTunnel).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
@@ -4,11 +4,14 @@ import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
@@ -19,6 +22,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const FLAT_APP_INFO_PLIST_BASE64 =
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
@@ -51,6 +57,57 @@ function createMockGraphqlClient({
} as unknown as Client;
}
function createSuccessfulResponse({
body,
url,
headers,
}: {
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
);
return {
ok: true,
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
},
} as unknown as Response;
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
try {
await Promise.all([
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
encoding: 'base64',
}),
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
]);
await tar.create(
{
cwd: sourceDirectory,
file: archivePath,
gzip: true,
},
['Info.plist', 'TestApp']
);
return await fs.promises.readFile(archivePath);
} finally {
await Promise.all([
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
fs.promises.rm(archivePath, { force: true }),
]);
}
}
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -58,11 +115,12 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -82,17 +140,39 @@ describe('downloadBuild', () => {
);
});
it('downloads an archive containing app bundle contents at its root', async () => {
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: await createFlatAppTarGzBufferAsync(),
url: APPLICATION_ARCHIVE_URL,
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
expect(path.extname(artifactPath)).toBe('.app');
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
);
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: applicationArchiveUrl,
} as unknown as Response);
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -106,6 +186,266 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient,
robotAccessToken: 'scoped-eas-token',
extensions: ['apk'],
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
applicationArchiveUrl,
expect.objectContaining({ headers: undefined })
);
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the Content-Disposition filename for an extensionless URL', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/download';
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: Buffer.from('hello'),
url: applicationArchiveUrl,
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('app.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the expected extension when the URL has no filename', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/';
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('application.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('passes the abort signal to the build lookup', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const buildId = randomUUID();
await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: new AbortController().signal,
});
expect(graphqlClient.query).toHaveBeenCalledWith(
expect.anything(),
{ buildId },
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ signal: expect.any(Object) })
);
});
it('stops a pending build lookup when aborted', async () => {
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
// hangs until its signal fires, like a stalled API call.
const realFetch = globalThis.fetch;
globalThis.fetch = jest.fn(
(_input: unknown, init?: RequestInit) =>
new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
})
) as typeof globalThis.fetch;
const graphqlClient = {
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
toPromise: () =>
context.fetch('https://api.expo.dev/graphql', {}).then(
() => ({ data: undefined }),
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
),
})),
} as unknown as Client;
const controller = new AbortController();
const failure = new Error('daemon failed');
try {
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
controller.abort(failure);
await expect(download).rejects.toBe(failure);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
it('lets a started extraction finish before it reports an abort', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
const controller = new AbortController();
const failure = new Error('daemon failed');
let extractionDirectory: string | undefined;
let extractionFinished = false;
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
extractionDirectory = args.destinationDirectory;
// The startup fails while the archive is being extracted.
controller.abort(failure);
await decompressTarAsync(args);
extractionFinished = true;
});
try {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
})
).rejects.toBe(failure);
// The download settled only after the extraction wrote its last file.
expect(extractionFinished).toBe(true);
expect(
await fs.promises.readFile(
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
'utf8'
)
).toBe('i am executable\n');
} finally {
decompressSpy.mockRestore();
}
});
it('does not look up the build when already aborted', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const failure = new Error('boot failed');
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: AbortSignal.abort(failure),
})
).rejects.toBe(failure);
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass buildId or applicationArchiveUrl.',
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass only one of buildId or applicationArchiveUrl.',
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects a non-HTTP application archive URL', async () => {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: 'file:///tmp/app.apk',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
});
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
@@ -203,11 +543,12 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
await expect(
downloadBuildAsync({
@@ -222,6 +563,34 @@ describe('downloadBuild', () => {
});
describe('createDownloadBuildFunction', () => {
it('aborts a pending archive request when the step times out', async () => {
let requestAborted = false;
jest.mocked(fetch).mockImplementation(
async (_url, options) =>
await new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
requestAborted = true;
reject(new Error('Archive request aborted'));
});
})
);
const graphqlClient = createMockGraphqlClient({});
const step = createDownloadBuildFunction({
graphqlClient,
} as any).createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
timeoutMs: 100,
}
);
await expect(step.executeAsync()).rejects.toThrow('timed out');
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestAborted).toBe(true);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -0,0 +1,158 @@
import { BuildRuntimePlatform } 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 { createMockLogger } from '../../../__tests__/utils/logger';
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const temporaryDirectories: string[] = [];
async function makeTemporaryDirectoryAsync(): Promise<string> {
const temporaryDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-build-test-')
);
temporaryDirectories.push(temporaryDirectory);
return temporaryDirectory;
}
describe(installBuildAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
afterAll(async () => {
await Promise.all(
temporaryDirectories.map(temporaryDirectory =>
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
)
);
});
it('installs an iOS Simulator .app', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'Example.app');
await fs.promises.mkdir(artifactPath);
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
[
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
{ stdio: 'pipe', env: {} },
],
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
]);
});
it('installs an Android Emulator .apk', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
});
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it.each([
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
[BuildRuntimePlatform.LINUX, 'example.aab'],
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, filename);
await fs.promises.writeFile(artifactPath, 'artifact');
await expect(
installBuildAsync({
artifactPath,
runtimePlatform,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('installs an Android artifact without a launchable activity', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValue({
stdout: "package: name='com.example.app'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it('exposes the installed application identifier and activity as step outputs', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const installBuild = createInstallBuildFunction();
const buildStep = installBuild.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{ callInputs: { artifact_path: artifactPath } }
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
});
});
@@ -1,4 +1,10 @@
import downloadFile from '@expo/downloader';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { BuildRuntimePlatform } from '@expo/steps';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
@@ -9,6 +15,11 @@ jest.mock('@expo/turtle-spawn', () => ({
default: jest.fn(),
}));
jest.mock('@expo/downloader', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
@@ -16,10 +27,24 @@ jest.mock('../../../datadog', () => ({
}));
const mockedSpawn = jest.mocked(spawn);
const mockedDownloadFile = jest.mocked(downloadFile);
async function writeInstalledWdaVersion(homeDirectory: string, version: string): Promise<void> {
const wdaDirectory = path.join(
homeDirectory,
'.maestro-runner',
'drivers',
'ios',
'WebDriverAgent'
);
await fs.promises.mkdir(wdaDirectory, { recursive: true });
await fs.promises.writeFile(path.join(wdaDirectory, 'package.json'), JSON.stringify({ version }));
}
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedDownloadFile.mockResolvedValue(undefined);
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
@@ -179,7 +204,7 @@ describe('createInstallMaestroBuildFunction', () => {
['--version'],
expect.objectContaining({
env: expect.objectContaining({
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
PATH: expect.stringMatching(/^\/home\/expo\/\.maestro-runner\/bin:/),
}),
})
);
@@ -189,6 +214,217 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it('installs maestro-runner 1.1.15 on Xcode versions below 26', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'xcodebuild':
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
case 'maestro-runner':
return {
stdout: versionChecks++ === 0 ? 'maestro-runner 1.1.23\n' : 'maestro-runner 1.1.15\n',
};
default:
return { stdout: '' };
}
}) as any);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
['--version', '1.1.15'],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
} finally {
fetchSpy.mockRestore();
process.env.PATH = originalPath;
}
});
it('does not check Xcode when a compatible maestro-runner version is installed', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'xcodebuild') {
throw new Error('xcodebuild should not be called');
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.15\n' : '' };
}) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).not.toHaveBeenCalledWith(
'xcodebuild',
expect.anything(),
expect.anything()
);
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
});
it('checks Xcode for latest when a compatible maestro-runner version is installed', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'xcodebuild':
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
case 'maestro-runner':
return { stdout: 'maestro-runner 1.1.15\n' };
default:
return { stdout: '' };
}
}) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner', maestro_version: 'latest' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'xcodebuild',
['-version'],
expect.objectContaining({ env: expect.anything() })
);
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
});
it('installs the latest maestro-runner version on Xcode 26', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.23\n' : '' };
}) as any);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
[],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.23');
} finally {
fetchSpy.mockRestore();
process.env.PATH = originalPath;
}
});
it('throws a system error when the Xcode version cannot be determined', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'xcodebuild') {
return { stdout: 'unexpected output' };
}
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('rejects a requested maestro-runner version that is incompatible with Xcode', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'xcodebuild') {
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
}
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner', maestro_version: '1.1.23' },
});
const executePromise = step.executeAsync();
await expect(executePromise).rejects.toThrow(UserError);
await expect(executePromise).rejects.toMatchObject({
errorCode: 'ERR_MAESTRO_INVALID_INPUT',
message:
'maestro-runner 1.1.23 is not compatible with Xcode 16.4.0. Use maestro-runner 1.1.15 or an Xcode 26+ image.',
});
});
it('installs the requested maestro-runner version', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
@@ -226,4 +462,184 @@ describe('createInstallMaestroBuildFunction', () => {
process.env.PATH = originalPath;
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
// The installed WDA version is unknown (no package.json under $HOME), so the
// cache download is skipped before resolving the Xcode/runtime versions.
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
});
it('continues when the prebuilt WDA cache is not available', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
isAvailable: true,
version: '26.5',
},
],
}),
};
default:
return { stdout: '' };
}
}) as any);
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
]);
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
});
@@ -0,0 +1,69 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createStep(env: Record<string, string>): BuildStep {
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
globalCtx.updateEnv(env);
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
}
it('does not install when mitmdump is already on PATH', async () => {
mockedSpawn.mockResolvedValueOnce({} as never);
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('warns instead of installing outside EAS Build VMs', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
await createStep({}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('installs the cask with Homebrew and leaves auto-update off', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({} as never);
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'brew',
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error('not found'));
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,320 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(launchApplicationAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
it('launches an iOS Simulator application by bundle identifier', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app'],
{ env: {}, logger }
);
});
it('passes launch arguments and opens a URL on iOS', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'host.exp.Exponent',
launchArgs: ['--uitesting', 'true'],
openUrl: 'exp://example.test',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'xcrun',
['simctl', 'launch', 'booted', 'host.exp.Exponent', '--uitesting', 'true'],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xcrun',
[
'simctl',
'spawn',
'booted',
'defaults',
'write',
'com.apple.launchservices.schemeapproval',
'com.apple.CoreSimulator.CoreSimulatorBridge-->exp',
'-string',
'host.exp.Exponent',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
3,
'xcrun',
['simctl', 'openurl', 'booted', 'exp://example.test'],
{ env: {}, logger }
);
});
it('opens a web URL on iOS without preapproving its scheme', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
openUrl: 'https://example.test',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app'],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xcrun',
['simctl', 'openurl', 'booted', 'https://example.test'],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenCalledTimes(2);
});
it('opens a custom URL on iOS when scheme preapproval fails', async () => {
const logger = createMockLogger();
const approvalError = new Error('Scheme approval is unavailable.');
mockedSpawn
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockRejectedValueOnce(approvalError);
await launchApplicationAsync({
applicationIdentifier: 'host.exp.Exponent',
openUrl: 'exp://example.test',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
3,
'xcrun',
['simctl', 'openurl', 'booted', 'exp://example.test'],
{ env: {}, logger }
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: approvalError },
'Could not preapprove the exp URL scheme for host.exp.Exponent. Opening the URL anyway; the Simulator might require manual confirmation.'
);
});
it('launches an Android Emulator application by package and activity', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
{ env: {}, logger }
);
});
it('passes launch arguments and opens a URL on Android', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'host.exp.exponent',
activityName: 'host.exp.exponent.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
openUrl: 'exp://example.test',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'--ez',
'isTest',
'true',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
);
});
it('requires an activity when launching an Android application', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--ez', 'isTest', 'true'],
open_url: 'exp://example.test',
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'--ez',
'isTest',
'true',
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
});
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
{
callInputs: {
application_identifier: 'com.example.app',
launch_args: launchArgs,
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: 'Input "launch_args" must be an array of strings.',
});
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects an invalid URL to open', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
{
callInputs: {
application_identifier: 'com.example.app',
open_url: 'not a URL',
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: 'Input "open_url" must be a valid URL.',
});
expect(mockedSpawn).not.toHaveBeenCalled();
});
it.each(['application_identifier', 'activity_name'])(
'reports an empty %s input as a user error',
async inputName => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
[inputName]: '',
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
});
expect(mockedSpawn).not.toHaveBeenCalled();
}
);
});
@@ -10,11 +10,92 @@ import {
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseFailedFlowsFromMaestroRunnerReport,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerReport,
} from '../maestroResultParser';
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
it('returns exact sourceFile paths for failed flows', async () => {
vol.fromJSON({
'/project/flows/pass.yml': '',
'/project/flows/nested/fail.yml': '',
'/reports/attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
{
name: 'Failing flow',
sourceFile: 'flows/nested/fail.yml',
status: 'failed',
},
{ status: 'skipped' },
],
}),
});
await expect(
parseFailedFlowsFromMaestroRunnerReport({
reportDirectory: '/reports/attempt-0',
workingDirectory: '/project',
})
).resolves.toEqual(['flows/nested/fail.yml']);
await expect(parseMaestroRunnerReport('/reports/attempt-0')).resolves.toEqual({
flows: [
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
{
name: 'Failing flow',
sourceFile: 'flows/nested/fail.yml',
status: 'failed',
},
],
});
});
it.each([
{
name: 'incomplete flow',
report: {
version: '1.0.0',
status: 'failed',
summary: { total: 1 },
flows: [{ name: 'Failing flow', sourceFile: 'flows/fail.yml', status: 'running' }],
},
},
])('returns null for an $name', async ({ report }) => {
vol.fromJSON({
'/project/flows/fail.yml': '',
'/reports/attempt-0/report.json': JSON.stringify(report),
});
await expect(
parseFailedFlowsFromMaestroRunnerReport({
reportDirectory: '/reports/attempt-0',
workingDirectory: '/project',
})
).resolves.toBeNull();
});
it('returns null when a failed sourceFile does not exist', async () => {
vol.fromJSON({
'/reports/attempt-0/report.json': JSON.stringify({
version: '1.0.0',
status: 'failed',
summary: { total: 1 },
flows: [{ name: 'Failing flow', sourceFile: 'flows/missing.yml', status: 'failed' }],
}),
});
await expect(
parseFailedFlowsFromMaestroRunnerReport({
reportDirectory: '/reports/attempt-0',
workingDirectory: '/project',
})
).resolves.toBeNull();
});
});
describe(parseFailedFlowNamesFromJUnitFile, () => {
it('returns the names of failed testcases, keeping slashes', async () => {
vol.fromJSON({
@@ -453,6 +534,20 @@ describe('junitFileHasFileAttrs', () => {
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns true for a maestro-runner report with a file property', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" time="1.0">',
' <properties><property name="file" value="flows/a.yaml"/></properties>',
' </testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
});
it('returns false when the file cannot be read', async () => {
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
});
@@ -478,6 +573,41 @@ describe(parseJUnitTestCases, () => {
expect(results[0].file).toBe('.maestro/login.yaml');
});
it('parses maestro-runner file properties and standard JUnit pass status', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" time="1.0">',
' <properties><property name="file" value="flows/login.yaml"/></properties>',
' </testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0]).toEqual(
expect.objectContaining({ file: 'flows/login.yaml', status: 'passed' })
);
});
it('excludes skipped testcases instead of counting them as passed', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="passing" time="1.0"/>',
' <testcase name="skipped-flow" time="0"><skipped/></testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results.map(r => r.name)).toEqual(['passing']);
});
it('treats a missing or empty file= attribute as undefined', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -3,14 +3,160 @@ import os from 'os';
import path from 'path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
harvestMaestroRunnerFailureScreenshotsAsync,
parseFailureScreenshotFilename,
selectFailureScreenshots,
} from '../maestroScreenshots';
describe(harvestMaestroRunnerFailureScreenshotsAsync, () => {
const logger = createMockLogger();
let reportDirectory: string;
let captureSpy: jest.SpiedFunction<typeof Sentry.capture>;
beforeEach(async () => {
reportDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-runner-harvest-test-'));
await fs.mkdir(path.join(reportDirectory, 'flows'));
await fs.mkdir(path.join(reportDirectory, 'assets', 'flow-000'), { recursive: true });
captureSpy = jest.spyOn(Sentry, 'capture').mockImplementation(() => {});
});
afterEach(async () => {
await fs.rm(reportDirectory, { recursive: true, force: true });
captureSpy.mockRestore();
});
it('reads the failed command screenshot from a maestro-runner report bundle', async () => {
const capturedSinceMs = Date.now() - 1_000;
await fs.writeFile(
path.join(reportDirectory, 'report.json'),
JSON.stringify({
flows: [
{
name: 'authentication/Login',
status: 'failed',
dataFile: 'flows/flow-000.json',
},
],
})
);
await fs.writeFile(
path.join(reportDirectory, 'flows', 'flow-000.json'),
JSON.stringify({
commands: [
{
status: 'failed',
artifacts: { screenshotAfter: 'assets/flow-000/cmd-001-after.png' },
},
],
})
);
const screenshotPath = path.join(reportDirectory, 'assets', 'flow-000', 'cmd-001-after.png');
await fs.writeFile(screenshotPath, 'png');
const shots = await harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory,
capturedSinceMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([
{
fileAbsPath: screenshotPath,
displayName: 'Failure Screenshot: authentication_Login (attempt 2)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'authentication_Login',
attemptIndex: 1,
capturedAtMs: expect.any(Number),
},
},
]);
});
it('rejects a screenshot path that resolves to the parent directory (exact "..")', async () => {
await fs.writeFile(
path.join(reportDirectory, 'report.json'),
JSON.stringify({
flows: [{ name: 'Login', status: 'failed', dataFile: 'flows/flow-000.json' }],
})
);
await fs.writeFile(
path.join(reportDirectory, 'flows', 'flow-000.json'),
JSON.stringify({
commands: [{ status: 'failed', artifacts: { screenshotAfter: '..' } }],
})
);
await expect(
harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory,
capturedSinceMs: 0,
attemptIndex: 0,
logger,
})
).resolves.toEqual([]);
});
it('ignores passing flows and paths outside the report directory', async () => {
await fs.writeFile(
path.join(reportDirectory, 'report.json'),
JSON.stringify({
flows: [
{ name: 'Passing', status: 'passed', dataFile: 'flows/flow-000.json' },
{ name: 'Unsafe', status: 'failed', dataFile: '../outside.json' },
],
})
);
await expect(
harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory,
capturedSinceMs: 0,
attemptIndex: 0,
logger,
})
).resolves.toEqual([]);
});
it.each([
['null', 'null'],
['a non-object flows value', JSON.stringify({ flows: 5 })],
['a null flow entry', JSON.stringify({ flows: [null] })],
])('reports to Sentry and returns [] when report.json is %s', async (_label, contents) => {
await fs.writeFile(path.join(reportDirectory, 'report.json'), contents);
await expect(
harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory,
capturedSinceMs: 0,
attemptIndex: 0,
logger,
})
).resolves.toEqual([]);
expect(captureSpy).toHaveBeenCalledTimes(1);
});
it('returns [] without reporting to Sentry when report.json is not valid JSON', async () => {
await fs.writeFile(path.join(reportDirectory, 'report.json'), 'not-json');
await expect(
harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory,
capturedSinceMs: 0,
attemptIndex: 0,
logger,
})
).resolves.toEqual([]);
expect(captureSpy).not.toHaveBeenCalled();
});
});
describe(parseFailureScreenshotFilename, () => {
it('parses a plain failure screenshot', () => {
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
@@ -27,10 +27,14 @@ jest.mock('../../../utils/retry', () => ({
jest.mock('../maestroScreenshots', () => ({
...jest.requireActual('../maestroScreenshots'),
harvestFailureScreenshotsAsync: jest.fn(),
harvestMaestroRunnerFailureScreenshotsAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
const mockedRunnerHarvest = jest.mocked(
maestroScreenshots.harvestMaestroRunnerFailureScreenshotsAsync
);
const mockUploadArtifact = jest.fn();
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
@@ -89,6 +93,8 @@ describe('createMaestroTestsBuildFunction', () => {
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockedRunnerHarvest.mockReset();
mockedRunnerHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
@@ -137,6 +143,276 @@ describe('createMaestroTestsBuildFunction', () => {
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('runs maestro-runner and collects its JUnit report when selected by input', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const junitFailureNamesSpy = jest.spyOn(parser, 'parseFailedFlowNamesFromJUnitFile');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'ios',
backend: 'maestro-runner',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [command, args] = mockedSpawn.mock.calls[0];
expect(command).toBe('maestro-runner');
expect(args).toEqual([
'--platform=ios',
'test',
'--output=/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
'--flatten',
'flows/a.yaml',
'flows/b.yaml',
]);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/ios-maestro-junit-attempt-0.xml'
);
expect(mockedHarvest).not.toHaveBeenCalled();
expect(junitFailureNamesSpy).not.toHaveBeenCalled();
expect(mockedRunnerHarvest).toHaveBeenCalledWith({
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
capturedSinceMs: expect.any(Number),
attemptIndex: 0,
logger: expect.anything(),
});
});
it('clears the deterministic maestro-runner output directory before each attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'ios',
backend: 'maestro-runner',
});
await step.executeAsync();
expect(rmSpy).toHaveBeenCalledWith('/home/expo/.maestro/tests/ios-maestro-runner-attempt-0', {
recursive: true,
force: true,
});
// Cleared before the run, so a crash before fresh output can't resurrect stale results.
expect(rmSpy.mock.invocationCallOrder[0]).toBeLessThan(mockedSpawn.mock.invocationCallOrder[0]);
});
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep(
{ flow_path: ['flows/a.yaml'], platform: 'android' },
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
);
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
});
it('prefers the backend input over EAS_MAESTRO_BACKEND', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{ flow_path: ['flows/a.yaml'], platform: 'android', backend: 'maestro' },
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
);
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
});
it('passes tags to maestro-runner', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
include_tags: 'smoke',
exclude_tags: 'slow',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['--include-tags=smoke', '--exclude-tags=slow'])
);
});
it('rejects invalid backend values', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'other',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalled();
});
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',
backend: 'maestro-runner',
shards: 2,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it.each(['html', 'allure'])(
'accepts %s output_format with maestro-runner and keeps JUnit',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: format,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining([
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
])
);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
);
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
expect(step.getOutputValueByName('final_report_path')).toBe(
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
);
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
],
}),
})
);
}
);
it('does not expose a selected runner report before an attempt starts', async () => {
const step = createStep({
flow_path: [],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/home/expo/.maestro/tests/junit-reports'
);
});
it('rejects an unknown output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'csv',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner supports "junit", "html", and "allure" output_format values'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uploads the last runner HTML report even when the test fails', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
retries: 1,
retry_failed_only: false,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro Runner HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
}),
})
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
);
});
it('does not fail a passing test when the runner report upload fails', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'allure',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('logs that maestro-runner does not support direct DADB', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
android_connection_mode: 'dadb',
},
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
);
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
expect(logger.info).toHaveBeenCalledWith(
'maestro-runner does not support DADB. Using the default ADB connection.'
);
});
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const writeFileSpy = jest.spyOn(fs, 'writeFile');
@@ -453,6 +729,36 @@ describe('createMaestroTestsBuildFunction', () => {
expect(a1Args).not.toContain('flows/c.yaml');
});
it('uses maestro-runner report.json for junit retries', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const runnerReportSpy = jest
.spyOn(parser, 'parseFailedFlowsFromMaestroRunnerReport')
.mockResolvedValue(['flows/b.yaml']);
const junitParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
platform: 'ios',
backend: 'maestro-runner',
});
await step.executeAsync();
expect(runnerReportSpy).toHaveBeenCalledWith({
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
workingDirectory: expect.any(String),
});
expect(junitParseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
const retryArgs = mockedSpawn.mock.calls[1][1]!;
expect(retryArgs).toContain('flows/b.yaml');
expect(retryArgs).not.toContain('flows/a.yaml');
});
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
@@ -764,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
@@ -775,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-html.html'
);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('warns that Maestro CLI does not support Allure reports', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'allure',
platform: 'android',
},
{ logger }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(logger.warn).toHaveBeenCalledWith(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
retries: 1,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(
removeSpy.mock.calls.filter(
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
)
).toHaveLength(2);
expect(removeSpy).toHaveBeenNthCalledWith(
1,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy).toHaveBeenNthCalledWith(
2,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[0]
);
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[1]
);
});
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
jest.spyOn(fs, 'rm').mockImplementation(async target => {
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
throw new Error('permission denied');
}
});
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
@@ -1014,6 +1428,32 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it.each(['junit', undefined])(
'uploads runner screenshots with output_format=%s',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
...(format === undefined ? {} : { output_format: format }),
backend: 'maestro-runner',
});
await step.executeAsync();
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
}
);
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const shot = makeShot(0);
@@ -1076,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
dtPlatformName: 'iphoneos',
});
});
@@ -0,0 +1,222 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
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,
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
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', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/argentEvents', () => ({
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/appiumEvents', () => ({
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
}));
jest.mock('../../utils/localEgress', () => ({
...jest.requireActual('../../utils/localEgress'),
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
getNgrokTunnelDomainOrThrow: () => 'example.test',
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
selectXcodeDeveloperDirectoryAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
const fields = {
egressUrl: handoff.url,
egressToken: handoff.token,
egressFingerprint: handoff.fingerprint,
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
] as const;
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
signal,
} as never
);
}
beforeEach(() => {
jest.clearAllMocks();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
jest
.spyOn(fs.promises, 'readFile')
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
closeAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
logger,
signal: expect.any(AbortSignal),
});
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
it('leaves ordinary remote config unchanged and does not monitor', async () => {
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
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(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
await expect(run()).rejects.toThrow(error);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
);
}
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
await run(abort.signal);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
it('does not report or start a monitor after cancellation', async () => {
const abort = new AbortController();
abort.abort();
await expect(run(abort.signal)).rejects.toThrow();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,5 @@
import { type bunyan } from '@expo/logger';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
@@ -193,22 +194,53 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).not.toBeNull();
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
expect(signingOptions?.provisioningProfile).toEqual({
'com.example.testapp': '/tmp/ios_provisioning_profile',
expect(signingOptions).toMatchObject({
keychainPath: '/tmp/ios_keychain',
signingIdentity: 'Test App Certificate',
provisioningProfile: {
'com.example.testapp': '/tmp/ios_provisioning_profile',
},
});
});
it('should resolve zsign signing options without touching the keychain', async () => {
const job = createTestIosJob();
const tmpDir = '/tmp';
vol.mkdirSync(tmpDir, { recursive: true });
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
backend: 'zsign',
tmpDir,
});
expect(credentialsManagerSpy).not.toHaveBeenCalled();
expect(signingOptions).toMatchObject({
backend: 'zsign',
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
});
assert(signingOptions?.backend === 'zsign');
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
assert(typeof signingOptions.provisioningProfile === 'object');
const profilePath = signingOptions.provisioningProfile['testapp'];
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
expect(vol.existsSync(profilePath)).toBe(true);
});
it('should return undefined if no build credentials are provided', async () => {
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
const job = createTestIosJob({ buildCredentials: null });
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -219,6 +251,7 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -0,0 +1,157 @@
import { type bunyan } from '@expo/logger';
import { Platform } from '@expo/eas-build-job';
import fs from 'fs';
import fetch from 'node-fetch';
import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
import { decompressCacheAsync } from '../restoreCache';
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
function createLoggerMock(): bunyan {
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
}
describe('cache compress/decompress round trip', () => {
it('preserves multiple sibling directories archived together', async () => {
const logger = createLoggerMock();
const cachesDir = path.join(os.tmpdir(), 'gradle-caches');
const buildCacheDir = path.join(cachesDir, 'build-cache-1');
const journalDir = path.join(cachesDir, 'journal-1');
await fs.promises.mkdir(buildCacheDir, { recursive: true });
await fs.promises.mkdir(journalDir, { recursive: true });
await fs.promises.writeFile(path.join(buildCacheDir, 'entry'), 'cache entry');
await fs.promises.writeFile(path.join(journalDir, 'file-access.properties'), 'inception=1');
const { archivePath } = await compressCacheAsync({
paths: [buildCacheDir, journalDir],
workingDirectory: cachesDir,
verbose: false,
logger,
});
const restoreDir = path.join(os.tmpdir(), 'gradle-caches-restored');
await fs.promises.mkdir(restoreDir, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: restoreDir,
verbose: false,
logger,
});
expect(
await fs.promises.readFile(path.join(restoreDir, 'build-cache-1', 'entry'), 'utf8')
).toBe('cache entry');
expect(
await fs.promises.readFile(
path.join(restoreDir, 'journal-1', 'file-access.properties'),
'utf8'
)
).toBe('inception=1');
});
it('preserves file modification times so journal-fallback entries can still age out', async () => {
const logger = createLoggerMock();
const sourceDir = path.join(os.tmpdir(), 'mtime-source');
await fs.promises.mkdir(sourceDir, { recursive: true });
const filePath = path.join(sourceDir, 'entry');
await fs.promises.writeFile(filePath, 'cache entry');
// A whole-second value in the past — tar stores mtimes at one-second resolution.
const mtime = new Date('2026-08-01T00:00:00.000Z');
await fs.promises.utimes(filePath, mtime, mtime);
const { archivePath } = await compressCacheAsync({
paths: [sourceDir],
workingDirectory: sourceDir,
verbose: false,
logger,
});
const restoreDir = path.join(os.tmpdir(), 'mtime-restored');
await fs.promises.mkdir(restoreDir, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: restoreDir,
verbose: false,
logger,
});
const restoredStat = await fs.promises.stat(path.join(restoreDir, 'entry'));
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
});
});
describe(uploadCacheAsync, () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it.each([
['normal upload', undefined, false],
['forced upload', true, true],
] as const)('sends force for %s', async (_name, force, expectedForce) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
const archivePath = path.join(tempDir, 'cache.tar.gz');
await fs.promises.writeFile(archivePath, 'cache archive');
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
headers: {},
url: 'https://storage.expo.test/cache',
},
}),
{ status: 200 }
)
)
.mockImplementationOnce(async (_url, request) => {
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
for await (const _chunk of body) {
// Consume the archive stream as a real upload would.
}
return new Response(undefined, { status: 200 });
});
try {
await uploadCacheAsync({
logger: createLoggerMock(),
jobId: 'build-id',
expoApiServerURL: 'https://api.expo.test',
robotAccessToken: 'robot-token',
paths: ['/cache/path'],
key: 'cache-key',
archivePath,
size: 13,
platform: Platform.ANDROID,
force,
});
expect(fetch).toHaveBeenCalledTimes(2);
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
expect(uploadSessionUrl.toString()).toBe(
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
);
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
buildId: 'build-id',
force: expectedForce,
key: 'cache-key',
size: 13,
});
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
'https://storage.expo.test/cache'
);
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,70 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/serveSimLogsRecorder');
jest.mock('../../utils/serveSimLogsArtifacts');
jest.mock('../../utils/remoteDeviceRunSession');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const step = { logger } as BuildStepContext;
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
beforeEach(() => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
});
it('starts collection and tolerates startup failures', async () => {
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not start serve-sim simulator logs',
expect.any(Error)
);
});
it('uploads each device with its own bounded signal', async () => {
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
{ udid: 'A', filePath: '/tmp/A.ndjson' },
{ udid: 'B', filePath: '/tmp/B.ndjson' },
]);
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
udid: 'A',
filePath: '/tmp/A.ndjson',
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
);
});
it('skips empty collection and warns on finalization failure', async () => {
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
await expect(
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize serve-sim simulator logs',
expect.any(Error)
);
});
@@ -0,0 +1,427 @@
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 { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import {
createStartAgentDeviceRemoteSessionBuildFunction,
runAgentDeviceRemoteSessionAsync,
} 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(), child: jest.fn().mockReturnThis() };
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(), child: jest.fn().mockReturnThis() };
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('hands network capture to serve-sim on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
);
});
it('fails before starting the daemon when network capture is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('starts the session host without waiting for the agent-device daemon', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
let markHostStarted!: () => void;
const hostStarted = new Promise<void>(resolve => {
markHostStarted = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
markHostStarted();
return sessionHost;
});
// The daemon credentials only appear after the session host started. A sequential
// startup would wait here forever.
jest.mocked(waitForFileAsync).mockImplementation(async () => {
await hostStarted;
return { port: 5678, token: 'daemon-token' };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
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',
}),
})
);
});
it('stops the daemon and its tunnel when the session host fails to start', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest
.mocked(startDeviceSessionHostAsync)
.mockRejectedValue(new Error('serve-sim did not start'));
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'serve-sim did not start'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
// Whatever started is stopped.
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
jest.mocked(startNgrokTunnelAsync).mock.calls.length
);
});
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'no daemon credentials'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
launch: {},
capture: { networkCapture: false, networkCaptureFields: [] },
tasks,
device: typeof device === 'function' ? device(tasks) : device,
});
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
it('starts the daemon during the boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
ready.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
await expect(
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
).rejects.toThrow('no daemon credentials');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
options?: { signal?: AbortSignal }
) =>
command === 'bun' && args[0] === 'add'
? new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})
: Promise.resolve(undefined)) as never);
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
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,202 @@ 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('kills the install and does not fall back to git when aborted', async () => {
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})) as never
);
const controller = new AbortController();
const failure = new Error('app install failed');
const daemon = startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ signal: controller.signal })
);
expect(spawnDetached).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
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',
}),
})
);
});
});
@@ -0,0 +1,400 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
}));
jest.mock('../startAgentDeviceRemoteSession', () => ({
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
runAgentDeviceRemoteSessionAsync: jest.fn(),
}));
const graphqlClient = { query: jest.fn() };
const ctx = { graphqlClient } as unknown as CustomBuildContext;
const sessionEnv = {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: 'iPhone 17',
});
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
serialId: 'emulator-5554' as never,
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
extensions: ['app'],
graphqlClient,
robotAccessToken: 'robot-token',
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
);
});
it('waits for the download when the Simulator boots first', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
application_archive_url: 'https://example.test/app.tar.gz',
});
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
});
it('makes the session ready when the device boots if there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
build_id: 'build-id',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 300,
})
);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensions: ['apk'] })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
expect(installBuildAsync).not.toHaveBeenCalled();
});
/** A download that runs until its abort signal fires, like a stalled one. */
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('stops a stalled download and does not install when the daemon fails', async () => {
const download = mockStalledDownload();
mockSessionWithFailingDaemon();
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('rejects conflicting application inputs before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
application_archive_url: 'https://example.test/app.tar.gz',
})
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
).rejects.toThrow('launch_args and open_url need an application');
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('declares the step inputs', () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
network_capture_fields: ['header', 'response-body'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
})
);
});
it('rejects network capture on Android before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import {
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -19,6 +20,10 @@ jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -35,6 +40,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
@@ -65,6 +71,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedResolveLocalEgress.mockResolvedValue(null);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
@@ -83,6 +90,107 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
describe('local egress', () => {
function createLocalEgress() {
return {
launchGate: {
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
wrapperCommand: 'bash',
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
admitAsync: jest.fn(),
},
networkReadyTarget: { host: '192.0.2.1', port: 443 },
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
};
}
it('starts the emulator exactly as before without a local egress session', async () => {
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
serialId: 'emulator-default',
timeoutMs: 60_000,
logger: expect.anything(),
});
expect(
mockedSpawn.mock.calls.filter(
([command, args]) =>
['sudo', 'nft', 'systemd-run'].includes(command) ||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
)
).toEqual([]);
});
it('fences, gates and configures the emulator', async () => {
const localEgress = createLocalEgress();
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({ launchGate: localEgress.launchGate })
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-base' })
);
});
it('retries a boot whose local egress configuration fails', async () => {
const localEgress = createLocalEgress();
localEgress.configureBootedEmulatorAsync
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
.mockResolvedValueOnce(undefined);
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
await createStep().executeAsync();
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-1111' })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ serialId: 'emulator-2222' })
);
});
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
device_identifier: 'medium_phone',
system_image_package: systemImagePackage,
lcd_width: 720,
lcd_height: 1600,
lcd_density: 262,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'medium_phone',
systemImagePackage,
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 262,
})
);
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
);
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
@@ -0,0 +1,169 @@
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 {
createStartAppiumRemoteSessionBuildFunction,
installAppiumAsync,
resolveAppium3VersionSpec,
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
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;
describe(resolveAppium3VersionSpec, () => {
it('uses the worker-supported Appium 3 version by default', () => {
expect(resolveAppium3VersionSpec(undefined)).toBe('^3');
});
it.each(['3', '^3', '3.x', '3.5.0', '>=3 <4'])('accepts Appium 3 version %s', version => {
expect(resolveAppium3VersionSpec(version)).toBe(version);
});
it.each(['2', '2.19.0', 'latest', '4.0.0', '>=3'])('rejects version %s', version => {
expect(() => resolveAppium3VersionSpec(version)).toThrow(
`Appium 3 is required for EAS Simulator sessions. Received package version "${version}".`
);
});
});
describe(resolveAppiumDeviceAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
await expect(
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: 'ios-simulator-id',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
env: {},
filter: 'booted',
});
});
it('returns UiAutomator2 capabilities for the booted Android emulator', async () => {
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
await expect(
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'Android',
automationName: 'UiAutomator2',
driverName: 'uiautomator2',
udid: 'emulator-5554',
});
expect(AndroidEmulatorUtils.getAttachedDevicesAsync).toHaveBeenCalledWith({ env: {} });
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,11 +4,14 @@ 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';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -39,15 +42,18 @@ jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledAsync: jest.fn(),
...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(),
startNgrokTunnelAsync: jest.fn(),
startServeSimWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -58,6 +64,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
const mockStopAsync = jest.fn();
const mockTunnelStopAsync = jest.fn();
const mockPreviewStopAsync = jest.fn();
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
@@ -68,6 +75,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
mockStopAsync.mockResolvedValue(undefined);
mockTunnelStopAsync.mockResolvedValue(undefined);
mockPreviewStopAsync.mockResolvedValue(undefined);
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
stopAsync: mockStopAsync,
getLastEventObservedAt: () => undefined,
@@ -80,12 +88,22 @@ 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(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);
@@ -101,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);
@@ -120,27 +207,41 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
} as never
);
// (1) The event log flag is enabled, before the tool-server is launched.
// (1) FFmpeg setup starts in the background before Argent setup.
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: { EXISTING: 'value' },
logger: expect.anything(),
});
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[0]
);
// (2) The event log flag is enabled, before the tool-server is launched.
const spawnCalls = jest.mocked(spawn).mock.calls;
const enableEventLogIndex = spawnCalls.findIndex(
([command, args]) =>
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
command === 'bun' &&
Array.isArray(args) &&
args[0] === 'x' &&
args.includes('tool-server-event-log')
);
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (2) The tool-server and the collector are pinned to the exact same event log path.
// (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',
eventLogPath: EXPECTED_EVENT_LOG_PATH,
});
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
// afterwards, exactly once.
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -151,5 +252,138 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
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 bunx 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 => {
@@ -3,6 +3,12 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
import {
installLocalEgressGuardAsync,
resolveLocalEgressBootEnvironmentAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -16,14 +22,36 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
enableAccessibilitySettingsAsync: jest.fn(),
resolveUdidAsync: jest.fn(),
bootAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
jest.mock('../../utils/localEgress', () => ({
configureSimulatorProxyEnvironmentAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
installLocalEgressGuardAsync: jest.fn(),
resolveLocalEgressBootEnvironmentAsync: jest.fn(),
verifyLocalEgressGuardAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
const mockedInstallGuard = jest.mocked(installLocalEgressGuardAsync);
const mockedVerifyGuard = jest.mocked(verifyLocalEgressGuardAsync);
const mockedResolveBootEnvironment = jest.mocked(resolveLocalEgressBootEnvironmentAsync);
// Names resolve to udids the way the step expects; udids pass through.
const UDIDS: Record<string, string> = {
'iPhone 15': 'base',
'eas-simulator-1': 'clone-1',
'eas-simulator-2': 'clone-2',
};
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
@@ -41,27 +69,41 @@ describe(createStartIosSimulatorBuildFunction, () => {
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.resolveUdidAsync.mockImplementation(
async ({ deviceIdentifier }) => (UDIDS[deviceIdentifier] ?? deviceIdentifier) as any
);
mockedUtils.bootAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockImplementation(async ({ deviceIdentifier }) => ({
udid: deviceIdentifier as any,
}));
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
mockedConfigureProxyEnvironment.mockResolvedValue(false);
mockedInstallGuard.mockResolvedValue(false);
mockedVerifyGuard.mockResolvedValue(undefined);
mockedResolveBootEnvironment.mockResolvedValue(null);
});
it('does not enable accessibility settings by default', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
deviceIdentifier: 'base',
env: expect.any(Object),
launchdEnvironment: {},
});
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
deviceIdentifier: 'base',
env: expect.any(Object),
});
});
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({
device_identifier: 'iPhone 15',
count: 2,
@@ -89,11 +131,6 @@ describe(createStartIosSimulatorBuildFunction, () => {
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
@@ -110,6 +147,84 @@ describe(createStartIosSimulatorBuildFunction, () => {
});
});
it('installs the proxy environment and guard between boot and boot completion, then verifies the guard', async () => {
mockedInstallGuard.mockResolvedValue(true);
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
const udids = ['base', 'clone-1', 'clone-2'];
expect(
mockedUtils.bootAsync.mock.calls.map(([{ deviceIdentifier }]) => deviceIdentifier)
).toEqual(udids);
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
expect(mockedInstallGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
expect(mockedVerifyGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
for (const [callIndex] of udids.entries()) {
const bootOrder = mockedUtils.bootAsync.mock.invocationCallOrder[callIndex];
const configureOrder = mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
const guardOrder = mockedInstallGuard.mock.invocationCallOrder[callIndex];
const bootCompleteOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
const verifyOrder = mockedVerifyGuard.mock.invocationCallOrder[callIndex];
const readyOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
// launchd is up when boot returns and has spawned nothing yet: that is the
// only moment at which its environment reaches every process of the boot.
expect(guardOrder).toBeGreaterThan(bootOrder);
expect(configureOrder).toBeGreaterThan(guardOrder);
expect(configureOrder).toBeLessThan(bootCompleteOrder);
// The self-check needs a completed boot and runs before anything else.
expect(verifyOrder).toBeGreaterThan(bootCompleteOrder);
expect(verifyOrder).toBeLessThan(readyOrder);
}
});
it('boots with the local egress environment so the first processes inherit it', async () => {
mockedResolveBootEnvironment.mockResolvedValue({
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
https_proxy: 'http://127.0.0.1:8899',
});
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
for (const [{ deviceIdentifier, launchdEnvironment }] of mockedUtils.bootAsync.mock.calls) {
expect(['base', 'clone-1', 'clone-2']).toContain(deviceIdentifier);
expect(launchdEnvironment).toEqual({
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
https_proxy: 'http://127.0.0.1:8899',
});
}
});
it('boots with an empty launchd environment when no local egress session is active', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
deviceIdentifier: 'base',
env: expect.any(Object),
launchdEnvironment: {},
});
});
it('skips the guard verification when no local egress session is active', async () => {
mockedInstallGuard.mockResolvedValue(false);
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedVerifyGuard).not.toHaveBeenCalled();
});
it('fails the step when the guard cannot be installed or verified', async () => {
mockedInstallGuard.mockRejectedValueOnce(new Error('guard library missing'));
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
'guard library missing'
);
mockedInstallGuard.mockResolvedValue(true);
mockedVerifyGuard.mockRejectedValueOnce(new Error('self-check failed'));
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
'self-check failed'
);
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
@@ -0,0 +1,279 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import fs from 'node:fs';
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
import {
LOCAL_EGRESS_HANDOFF_PATH,
configureSystemProxyAsync,
downloadChiselAsync,
startChiselServerAsync,
stopLocalEgressResourcesAsync,
writeLocalEgressHandoffAsync,
} from '../../utils/localEgress';
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
jest.mock('../../utils/localEgress', () => ({
...jest.requireActual('../../utils/localEgress'),
configureSystemProxyAsync: jest.fn(),
downloadChiselAsync: jest.fn(),
startChiselServerAsync: jest.fn(),
writeLocalEgressHandoffAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
startAndroidLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
findAvailablePortAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
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, {
env: {},
inputs: {},
outputs: {},
signal,
});
}
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
await createStartLocalEgressBuildFunction().fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
env: {},
inputs: {},
outputs: {},
} as any
);
}
describe('local egress platforms', () => {
const androidStop = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
server.stopAsync.mockResolvedValue(undefined);
tunnel.stopAsync.mockResolvedValue(undefined);
androidStop.mockResolvedValue(undefined);
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('sets the macOS system proxy and writes the same handoff as before', async () => {
await startOn(BuildRuntimePlatform.DARWIN);
expect(configureSystemProxyAsync).toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
url: tunnel.url,
token: expect.any(String),
fingerprint: 'key=',
port: 8899,
});
});
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
await startOn(BuildRuntimePlatform.LINUX);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({
chiselPid: server.pid,
proxyPort: 8899,
controlPort: 52001,
workDir: '/tmp/egress-acquisition-test',
})
);
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'android' })
);
await stopLocalEgressResourcesAsync(logger);
expect(androidStop).toHaveBeenCalledTimes(1);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
});
it('releases the tunnel when the Android fence cannot start', async () => {
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
});
});
describe('local egress acquisition', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
server.stopAsync.mockResolvedValue(undefined);
tunnel.stopAsync.mockResolvedValue(undefined);
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
await expect(start()).rejects.toThrow('listener quota');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
recursive: true,
force: true,
});
});
it('attempts every release even when one resource cannot stop', async () => {
await start();
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
await stopLocalEgressResourcesAsync(logger);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not stop a local egress resource.'
);
});
it.each([false, true])(
'disposes late listeners without delaying cleanup (close fails: %s)',
async closeFails => {
let finishTunnel!: (value: typeof tunnel) => void;
jest.mocked(startNgrokTunnelAsync).mockImplementation(
() =>
new Promise(resolve => {
finishTunnel = resolve;
})
);
const started = start();
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
const stopped = stopLocalEgressResourcesAsync(logger);
await Promise.all([stopped, rejected]);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(tunnel.stopAsync).not.toHaveBeenCalled();
if (closeFails) {
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
}
finishTunnel(tunnel);
await new Promise(resolve => setImmediate(resolve));
if (closeFails) {
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not release a cancelled local egress acquisition.'
);
}
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
}
);
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
const controller = new AbortController();
const started = start(controller.signal);
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
if (source === 'step') {
controller.abort(new Error('cancelled'));
}
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
const outcome = await Promise.race([
Promise.all([stopped, rejected]).then(() => 'stopped'),
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
]);
expect(outcome).toBe('stopped');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
});
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
let rejectTunnel!: (error: Error) => void;
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
new Promise((_resolve, reject) => {
rejectTunnel = reject;
})
);
const started = start();
const rejected = expect(started).rejects.toThrow();
await new Promise(resolve => setImmediate(resolve));
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
rejectTunnel(new Error('late connection failure'));
await new Promise(resolve => setImmediate(resolve));
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
});
it('does not launch a server after a download finishes following cancellation', async () => {
let finishDownload!: (value: string) => void;
jest.mocked(downloadChiselAsync).mockImplementation(
() =>
new Promise(resolve => {
finishDownload = resolve;
})
);
const controller = new AbortController();
const started = start(controller.signal);
const rejected = expect(started).rejects.toThrow('cancelled');
await new Promise(resolve => setImmediate(resolve));
controller.abort(new Error('cancelled'));
await rejected;
finishDownload('/tmp/chisel');
await new Promise(resolve => setImmediate(resolve));
expect(startChiselServerAsync).not.toHaveBeenCalled();
expect(
jest
.mocked(fs.promises.rm)
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
).toHaveLength(2);
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
recursive: true,
force: true,
});
});
});
@@ -0,0 +1,159 @@
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
const started = new Promise<void>(resolve => {
requestStarted = resolve;
});
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
return await new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
requestStarted();
});
});
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
});
try {
const pending = markSandboxReadyAsync(
{ graphqlClient } as any,
'sandbox-id',
controller.signal
);
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
} finally {
fetchMock.mockRestore();
}
});
it('does not mark the sandbox ready if startup was canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const mutation = jest.fn();
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
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 () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: {},
mcpServerUrl: 'ws://localhost:8787',
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
})
).rejects.toBeInstanceOf(SystemError);
});
it('throws a system error when the MCP server URL is missing', async () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
}));
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
);
});
it('throws a system error when marking the sandbox as ready fails', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ error: new Error('request failed') }),
}));
await expect(
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
});
});
@@ -0,0 +1,220 @@
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,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
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,
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: '1.2.3' },
max_duration_seconds: { value: 120 },
...launchInputs,
},
outputs: {},
env,
} as never
);
}
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
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);
stopAsync.mockResolvedValue(undefined);
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
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],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: {
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
logger,
maxDurationSeconds: 120,
signal: undefined,
});
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',
'network_capture',
'network_capture_fields',
'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('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header', 'response-body'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
networkCapture: true,
networkCaptureFields: ['header', 'response-body'],
})
);
});
it('fails before starting anything when network capture is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
network_capture: { value: true },
})
).rejects.toThrow('this session runs on linux');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
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();
});
});
@@ -0,0 +1,533 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import {
createUpdateTestFlightMetadataBuildFunction,
updateTestFlightMetadataAsync,
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
const changelog = 'Test "quotes"\n$(not-a-command)';
const options = {
client: new AscApiClient({ key }),
buildUploadId: 'upload',
changelog,
groups: [] as string[],
logger: createMockLogger(),
};
const api = () => nock('https://api.appstoreconnect.apple.com');
function mockBuild(
state = 'COMPLETE',
buildId: string | null = 'build',
primaryLocale: string | null = 'en-US'
): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: {
type: 'buildUploads',
id: 'upload',
attributes: { state: { state } },
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
},
});
if (state === 'COMPLETE' && buildId) {
api()
.get(`/v1/builds/${buildId}/app`)
.query(true)
.reply(200, {
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
});
}
}
function mockAssignedGroups(ids: string[] = []): void {
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, { data: ids.map(id => ({ id })) });
}
beforeAll(() => nock.disableNetConnect());
beforeEach(() => {
options.logger = createMockLogger();
options.client = new AscApiClient({ key });
});
afterAll(() => nock.enableNetConnect());
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual(['A, "B"']);
expect(inputs.changelog.value).toBe(changelog);
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {
asc_api_key_path: '/tmp/key.json',
build_upload_id: 'upload',
groups: ['A, "B"'],
changelog,
},
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('uses empty metadata defaults', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual([]);
expect(inputs.changelog.value).toBe('');
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('skips automatic internal groups and groups that already contain the build', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'automatic',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
},
{ id: 'assigned', attributes: { name: 'QA' } },
{
id: 'manual',
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
},
],
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'manual' }],
})
.reply(204);
await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['Internal', 'QA', 'External'],
});
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"Internal" (automatic): automatic access')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"QA" (assigned): build already assigned')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"External" (manual): assignment completed')
);
});
it.each(['en-US', null])(
'creates a localization when none exist (primary locale: %s)',
async primaryLocale => {
mockBuild('COMPLETE', 'build', primaryLocale);
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await updateTestFlightMetadataAsync(options);
expect(options.logger.info).toHaveBeenCalledWith(
`Updating changelog: ${JSON.stringify(changelog)}`
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('Locale "en-US": localization created')
);
}
);
it('reads all localization pages and updates existing localizations without replacing them', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
},
});
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query({ limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
for (const id of ['en-US', 'pl']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`, {
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
})
.reply(200, { data: { id } });
}
await updateTestFlightMetadataAsync(options);
for (const id of ['en-US', 'pl']) {
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining(`Locale "${id}" (${id})`)
);
}
});
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, { data: [{ id: 'localization' }] });
api()
.patch('/v1/betaBuildLocalizations/localization', {
data: {
type: 'betaBuildLocalizations',
id: 'localization',
attributes: { whatsNew: changelog },
},
})
.reply(200, { data: { id: 'localization' } });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'primary' } });
await updateTestFlightMetadataAsync(options);
});
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'internal',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
},
{
id: 'external',
attributes: {
name: 'test external',
isInternalGroup: false,
hasAccessToAllBuilds: null,
publicLinkEnabled: false,
publicLink: 'https://testflight.apple.com/join/abc',
},
},
{
id: 'requested-external',
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
},
],
});
for (const id of ['internal', 'requested-external']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
).resolves.toBeUndefined();
});
it('adds groups without changing changelog or submitting beta review', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
});
it('reports missing groups independently of the changelog task', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(201, { data: { id: 'locale' } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
'The following TestFlight group was not found in App Store Connect: "missing".'
);
});
it('reads all pages and adds every group with a requested name', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [{ id: 'first', attributes: { name: 'A' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
.reply(200, {
data: [
{ id: 'second', attributes: { name: 'A' } },
{ id: 'other', attributes: { name: 'B' } },
],
});
for (const id of ['first', 'second']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it('assigns found groups before reporting missing requested names', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
);
});
it('attempts all localization changes and reports every failed update', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
});
for (const id of ['pl', 'fr', 'de']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`)
.reply(
id === 'de' ? 200 : 403,
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
);
}
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
});
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(422, {
errors: Array.from({ length: errorCount }, () => ({
code: 'ENTITY_UNPROCESSABLE',
title: 'Builds cannot be assigned to this internal group.',
detail: 'Cannot add internal group to a build.',
})),
});
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow(
"App Store Connect can't add this build to a requested internal TestFlight group."
);
});
it('stops after 20 group pages', async () => {
mockBuild();
const next =
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [], links: { next } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
.times(19)
.reply(200, { data: [], links: { next } });
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
});
it('tries group assignment if the changelog update fails', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
});
it('reports both write failures', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
/CHANGELOG_FAILED.*GROUPS_FAILED/
);
});
it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })) });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'A' }],
})
.reply(
multiple ? 403 : 204,
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
}
);
it('reads all membership pages before assigning groups', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, {
data: [],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'group' }] });
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
mockBuild(state);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('rejects a completed upload without its build relationship', async () => {
mockBuild('COMPLETE', null);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('propagates Apple write failures', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
});
@@ -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,
@@ -0,0 +1,46 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
const UPLOAD_TIMEOUT_MS = 30_000;
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_serve_sim_logs',
name: 'Collect serve-sim simulator logs',
__metricsId: 'eas/collect_serve_sim_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
try {
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
if (collected.length === 0) {
logger.info('No simulator logs collected; skipping upload.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
for (const { udid, filePath } of collected) {
// Each device gets its own budget so one stuck upload does not cancel the rest.
await uploadServeSimLogsFileAsync(ctx, {
deviceRunSessionId,
udid,
filePath,
logger,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finalize serve-sim simulator logs', error);
logger.warn(
{ err: error },
'Could not finalize simulator logs; the session result is unchanged.'
);
}
},
});
}
@@ -8,24 +8,33 @@ import {
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import bplistParser from 'bplist-parser';
import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
type DownloadBuildSource =
| { buildId: string; applicationArchiveUrl?: never }
| { buildId?: never; applicationArchiveUrl: string };
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
@@ -49,7 +58,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
inputProviders: [
BuildStepInput.createProvider({
id: 'build_id',
required: true,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
@@ -65,20 +79,48 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs }) => {
fn: async (stepsCtx, { inputs, outputs, signal }) => {
const { logger } = stepsCtx;
const extensions = z.array(z.string()).parse(inputs.extensions.value);
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
const buildId = z.string().uuid().parse(inputs.build_id.value);
logger.info(`Downloading build ${buildId}...`);
const buildId = inputs.build_id.value
? z.string().uuid().parse(inputs.build_id.value)
: undefined;
const applicationArchiveUrl = inputs.application_archive_url.value
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
: undefined;
let source: DownloadBuildSource;
if (buildId) {
if (applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
source = { buildId };
} else {
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass build_id or application_archive_url.'
);
}
source = { applicationArchiveUrl };
}
logger.info(
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
);
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
...source,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
});
outputs.artifact_path.set(artifactPath);
@@ -89,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
signal,
}: {
buildId: string;
graphqlClient: Client;
signal?: AbortSignal;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
signal?.throwIfAborted();
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
@@ -115,42 +163,58 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
if (params.applicationArchiveUrl) {
if (params.buildId) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of buildId or applicationArchiveUrl.'
);
}
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass buildId or applicationArchiveUrl.'
);
}
const downloadDestinationDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
// URL may contain percent-encoded characters, e.g. my%20file.apk
// this replaces all non-alphanumeric characters (excluding dot) with underscore
const archiveFilename = path
.basename(new URL(response.url).pathname)
.replace(/([^a-z0-9.-]+)/gi, '_');
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -166,10 +230,15 @@ export async function downloadBuildAsync({
const extractionDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-extracted-')
);
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
// the extraction lets it finish (local, bounded work) and is reported right after it.
// This way no file is written after this function settles.
signal?.throwIfAborted();
await decompressTarAsync({
archivePath,
destinationDirectory: extractionDirectory,
});
signal?.throwIfAborted();
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
absolute: true,
@@ -177,6 +246,18 @@ export async function downloadBuildAsync({
onlyFiles: false,
onlyDirectories: false,
});
let matchingFilesRoot = extractionDirectory;
if (
matchingFiles.length === 0 &&
extensions.includes('app') &&
(await isIosAppBundleAsync(extractionDirectory))
) {
const appBundlePath = `${extractionDirectory}.app`;
await fs.promises.rename(extractionDirectory, appBundlePath);
matchingFiles.push(appBundlePath);
matchingFilesRoot = path.dirname(appBundlePath);
}
if (matchingFiles.length === 0) {
throw new UserError(
@@ -189,8 +270,68 @@ export async function downloadBuildAsync({
`Found ${matchingFiles.length} matching ${pluralize(
matchingFiles.length,
'entry'
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
);
return { artifactPath: matchingFiles[0] };
}
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
try {
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
const parsedInfoPlist = (
isBinaryPlist
? bplistParser.parseBuffer(infoPlist)[0]
: plist.parse(infoPlist.toString('utf8'))
) as Record<string, unknown> | undefined;
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
} catch {
return false;
}
}
function parseHttpApplicationArchiveUrl(value: unknown): string {
try {
const applicationArchiveUrl = z.string().parse(value);
const parsedUrl = new URL(applicationArchiveUrl);
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
return applicationArchiveUrl;
} catch {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
'application_archive_url must be a valid HTTP or HTTPS URL.'
);
}
}
function resolveArchiveFilename({
response,
extensions,
}: {
response: Awaited<ReturnType<typeof fetch>>;
extensions: string[];
}): string {
const contentDispositionHeader = response.headers.get('content-disposition');
let headerFilename: string | undefined;
if (contentDispositionHeader) {
try {
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
} catch {
// Ignore malformed Content-Disposition headers and fall back to the response URL.
}
}
const urlFilename = path.basename(new URL(response.url).pathname);
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
archiveFilename = 'application';
}
if (!path.extname(archiveFilename) && extensions.length === 1) {
archiveFilename = `${archiveFilename}.${extensions[0]}`;
}
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
}
@@ -0,0 +1,124 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
export function createInstallBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_build',
name: 'Install build',
__metricsId: 'eas/install_build',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'application_identifier',
required: true,
}),
BuildStepOutput.createProvider({
id: 'activity_name',
required: false,
}),
],
fn: async ({ global, logger }, { inputs, outputs, env }) => {
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
outputs.application_identifier.set(applicationIdentifier);
if (activityName) {
outputs.activity_name.set(activityName);
}
},
});
}
export async function installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger,
}: {
artifactPath: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
`Build artifact does not exist at ${artifactPath}.`,
{ cause: err }
);
});
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'iOS Simulator sessions require a .app build artifact.'
);
}
const infoPlistPath = path.join(artifactPath, 'Info.plist');
const { stdout } = await spawn(
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
{ stdio: 'pipe', env }
);
const applicationIdentifier = stdout.trim();
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
);
}
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
return { applicationIdentifier };
}
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'Android Emulator sessions require an .apk build artifact.'
);
}
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
stdio: 'pipe',
env,
});
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read an Android application identifier from ${artifactPath}.`
);
}
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
return { applicationIdentifier, activityName };
}
@@ -1,3 +1,5 @@
import downloadFile from '@expo/downloader';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -14,9 +16,15 @@ import assert from 'assert';
import fs from 'fs';
import os from 'os';
import path from 'path';
import semver from 'semver';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { Datadog } from '../../datadog';
import { getXcodeVersionAsync } from '../../ios/xcode';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { getProxiedDownloadUrl } from '../../utils/download';
const MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS = 20_000;
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -120,19 +128,66 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
await installIdbFromBrew({ logger, env });
}
// Skip installing if the input sets a specific Maestro version to install
// and it is already installed which happens when developing on a local computer.
if (
!currentMaestroVersion ||
(requestedVersion && requestedVersion !== currentMaestroVersion)
) {
switch (backend) {
case 'maestro':
switch (backend) {
case 'maestro':
// Skip installing if the input sets a specific Maestro version to install
// and it is already installed, either on a build image or a local computer.
if (
!currentMaestroVersion ||
(requestedVersion && requestedVersion !== currentMaestroVersion)
) {
await installMaestro({ version: requestedVersion, global, logger, env });
break;
case 'maestro-runner':
await installMaestroRunner({ version: requestedVersion, global, logger, env });
break;
}
break;
case 'maestro-runner': {
let maestroRunnerVersionToInstall = requestedVersion;
const currentMaestroRunnerVersion = semver.coerce(currentMaestroVersion)?.version;
const requestedMaestroRunnerVersion =
requestedVersion && requestedVersion !== 'latest'
? semver.valid(requestedVersion)
: null;
if (
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
((requestedMaestroRunnerVersion &&
semver.gte(requestedMaestroRunnerVersion, '1.1.16')) ||
requestedVersion === 'latest' ||
(requestedVersion === undefined &&
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
) {
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
// below 26 reject that option, so use the last compatible maestro-runner version.
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
if (requestedMaestroRunnerVersion) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
`maestro-runner ${requestedVersion} is not compatible with Xcode ${xcodeVersion}. Use maestro-runner 1.1.15 or an Xcode 26+ image.`
);
}
maestroRunnerVersionToInstall = '1.1.15';
logger.info(
`Xcode ${xcodeVersion} requires maestro-runner ${maestroRunnerVersionToInstall}.`
);
}
}
if (
!currentMaestroVersion ||
(maestroRunnerVersionToInstall &&
maestroRunnerVersionToInstall !== currentMaestroVersion)
) {
await installMaestroRunner({
version: maestroRunnerVersionToInstall,
global,
logger,
env,
});
}
break;
}
}
@@ -143,6 +198,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
throw new Error(`Failed to ensure ${backend} is installed.`);
}
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
await installMaestroRunnerWdaCache({
logger,
env,
});
}
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
@@ -154,6 +216,142 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
});
}
async function installMaestroRunnerWdaCache({
logger,
env,
}: {
logger: bunyan;
env: BuildStepEnv;
}): Promise<void> {
if (!env.HOME) {
logger.warn(
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
);
return;
}
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
try {
const wdaVersion = await getMaestroRunnerWdaVersion({ maestroRunnerHome });
if (!wdaVersion) {
logger.info(
'Skipping the prebuilt WebDriverAgent cache because the installed WDA version is unknown.'
);
return;
}
const xcodeVersion = await getXcodeVersionAsync({ env });
const iosRuntimes = await IosSimulatorUtils.getAvailableRuntimesAsync({ env });
const iosRuntimeIdentifierPrefix = 'com.apple.CoreSimulator.SimRuntime.iOS-';
// maestro-runner derives its WDA cache key from the runtime identifier, not from the runtime
// version. These values can differ for patched runtimes, for example the iOS-26-3 identifier
// can report version 26.3.1.
const iosRuntimeVersions = [
...new Set(
iosRuntimes.map(runtime =>
runtime.identifier.slice(iosRuntimeIdentifierPrefix.length).replaceAll('-', '.')
)
),
];
if (iosRuntimeVersions.length === 0) {
logger.info(
'Skipping the prebuilt WebDriverAgent cache because no iOS runtime is available.'
);
return;
}
const tempDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install_maestro_runner_wda_cache')
);
try {
const archivePath = path.join(tempDirectory, 'wda-cache.tar.gz');
const directArchiveUrl =
`https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/` +
`xcode-${encodeURIComponent(xcodeVersion)}-wda-${encodeURIComponent(wdaVersion)}.tar.gz`;
const proxiedArchiveUrl = getProxiedDownloadUrl({
directUrl: directArchiveUrl,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
logger.info(`Downloading the prebuilt WebDriverAgent cache for Xcode ${xcodeVersion}`);
if (proxiedArchiveUrl) {
try {
await downloadFile(proxiedArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
} catch (err) {
logger.debug(
{ err },
'Failed to download the prebuilt WebDriverAgent cache via the proxy; falling back to the direct URL.'
);
await downloadFile(directArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
}
} else {
await downloadFile(directArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
}
await fs.promises.mkdir(maestroRunnerHome, { recursive: true });
await spawn('tar', ['-xzf', archivePath, '-C', maestroRunnerHome], { logger, env });
const genericProductsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
for (const runtimeVersion of iosRuntimeVersions) {
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products'
);
await fs.promises.cp(genericProductsDirectory, productsDirectory, { recursive: true });
}
logger.info(
`Installed the prebuilt WebDriverAgent cache for iOS ${iosRuntimeVersions.join(', ')}.`
);
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
} catch (err: any) {
logger.warn(
{ err },
'Failed to install the prebuilt WebDriverAgent cache. maestro-runner will build WebDriverAgent when it is needed.'
);
}
}
async function getMaestroRunnerWdaVersion({
maestroRunnerHome,
}: {
maestroRunnerHome: string;
}): Promise<string | null> {
try {
const packageJson = JSON.parse(
await fs.promises.readFile(
path.join(maestroRunnerHome, 'drivers', 'ios', 'WebDriverAgent', 'package.json'),
'utf8'
)
);
return typeof packageJson.version === 'string' ? packageJson.version : null;
} catch {
return null;
}
}
async function getMaestroVersion({
env,
backend,
@@ -211,10 +409,10 @@ async function installMaestroRunner({
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
global.updateEnv({
...global.env,
PATH: `${global.env.PATH}:${binDir}`,
PATH: `${binDir}:${global.env.PATH}`,
});
env.PATH = `${env.PATH}:${binDir}`;
process.env.PATH = `${process.env.PATH}:${binDir}`;
env.PATH = `${binDir}:${env.PATH}`;
process.env.PATH = `${binDir}:${process.env.PATH}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
@@ -261,10 +459,10 @@ async function installMaestro({
const maestroBinDir = path.join(maestroDir, 'bin');
global.updateEnv({
...global.env,
PATH: `${global.env.PATH}:${maestroBinDir}`,
PATH: `${maestroBinDir}:${global.env.PATH}`,
});
env.PATH = `${env.PATH}:${maestroBinDir}`;
process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
env.PATH = `${maestroBinDir}:${env.PATH}`;
process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
@@ -272,37 +470,64 @@ async function installMaestro({
async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
try {
await spawn('idb', ['-h'], { ignoreStdio: true, env });
await spawn('idb_companion', ['--version'], { ignoreStdio: true, env });
return true;
} catch {
return false;
}
}
async function installIdbFromBrew({
export async function installIdbFromBrew({
logger,
env,
}: {
logger: bunyan;
env: BuildStepEnv;
}): Promise<void> {
// Unfortunately our Mac images sometimes have two Homebrew
// installations. We should use the ARM64 one, located in /opt/homebrew.
const brewPath = '/opt/homebrew/bin/brew';
const localEnv = {
...env,
HOMEBREW_NO_AUTO_UPDATE: '1',
HOMEBREW_NO_INSTALL_CLEANUP: '1',
};
try {
// Unfortunately our Mac images sometimes have two Homebrew
// installations. We should use the ARM64 one, located in /opt/homebrew.
const brewPath = '/opt/homebrew/bin/brew';
const localEnv = {
...env,
HOMEBREW_NO_AUTO_UPDATE: '1',
HOMEBREW_NO_INSTALL_CLEANUP: '1',
};
await spawn(brewPath, ['tap', 'facebook/fb'], {
env: localEnv,
logger,
});
await spawn(brewPath, ['install', 'idb-companion'], {
env: localEnv,
logger,
});
logger.info('Tapping facebook/fb...');
await spawn(brewPath, ['tap', 'facebook/fb'], {
env: localEnv,
logger,
});
const brewRepo = await spawn(brewPath, ['--repo', 'facebook/fb'], {
env: localEnv,
});
const tapPath = brewRepo.stdout.trim();
// c0386793f59da10c619787f2aa18d938ef1d69c9 is hash for 1.1.8 release,
// last known compatible version + post_install fix.
const gitSha = 'c0386793f59da10c619787f2aa18d938ef1d69c9';
logger.info('Checking out facebook/fb at [email protected]...');
await spawn('git', ['fetch', 'origin', gitSha], {
cwd: tapPath,
logger,
});
await spawn('git', ['checkout', gitSha], {
cwd: tapPath,
logger,
});
logger.info('Installing idb_companion v1.1.8...');
await spawn(brewPath, ['install', 'facebook/fb/idb-companion'], {
env: localEnv,
logger,
});
} catch (err) {
throw new SystemError('Failed to install idb-companion required for Maestro to run.', {
cause: err,
});
}
}
async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
@@ -0,0 +1,65 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { Sentry } from '../../sentry';
export function createInstallMitmproxyBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_mitmproxy',
name: 'Install mitmproxy',
__metricsId: 'eas/install_mitmproxy',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
if (await isMitmproxyAvailableAsync(env)) {
logger.info('mitmproxy is already installed.');
return;
}
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
logger.warn(
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
);
return;
}
try {
logger.info('Installing mitmproxy with Homebrew.');
await installMitmproxyWithHomebrewAsync({ env, logger });
if (!(await isMitmproxyAvailableAsync(env))) {
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
}
logger.info('Installed mitmproxy.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install mitmproxy for the device session', error, {
level: 'warning',
});
logger.warn(
{ err: error },
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
);
}
},
});
}
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
}
async function installMitmproxyWithHomebrewAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
}
@@ -0,0 +1,219 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
export function createLaunchApplicationFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'launch_application',
name: 'Launch application',
__metricsId: 'eas/launch_application',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_identifier',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'activity_name',
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,
}),
],
fn: async ({ global, logger }, { inputs, env }) => {
const applicationIdentifier = parseNonEmptyStringInput(
inputs.application_identifier.value,
'application_identifier'
);
const activityName =
inputs.activity_name.value === undefined
? undefined
: parseNonEmptyStringInput(inputs.activity_name.value, 'activity_name');
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
},
});
}
export async function launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs = [],
openUrl,
runtimePlatform,
env,
logger,
}: {
applicationIdentifier: string;
activityName?: string;
launchArgs?: string[];
openUrl?: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier, ...launchArgs], {
env,
logger,
});
if (openUrl) {
await preapproveIosUrlSchemeAsync({ applicationIdentifier, openUrl, env, logger });
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn('xcrun', ['simctl', 'openurl', 'booted', openUrl], { env, logger });
}
return;
}
if (!activityName) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY',
'Launching an Android application requires activity_name.'
);
}
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
}
);
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
openUrl,
'-n',
`${applicationIdentifier}/${activityName}`,
],
{ env, logger }
);
}
}
async function preapproveIosUrlSchemeAsync({
applicationIdentifier,
openUrl,
env,
logger,
}: {
applicationIdentifier: string;
openUrl: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
const urlScheme = new URL(openUrl).protocol.slice(0, -1);
if (urlScheme === 'http' || urlScheme === 'https') {
return;
}
try {
await spawn(
'xcrun',
[
'simctl',
'spawn',
'booted',
'defaults',
'write',
IOS_URL_SCHEME_APPROVAL_DOMAIN,
`${IOS_URL_SCHEME_APPROVAL_KEY_PREFIX}${urlScheme}`,
'-string',
applicationIdentifier,
],
{ env, logger }
);
} catch (error) {
logger.warn(
{ err: error },
`Could not preapprove the ${urlScheme} URL scheme for ${applicationIdentifier}. Opening the URL anyway; the Simulator might require manual confirmation.`
);
}
}
function logApplicationLaunch(
logger: bunyan,
applicationIdentifier: string,
launchArgs: string[]
): void {
const argumentsDescription =
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
}
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`
);
}
return value;
}
export function parseLaunchArgsInput(value: unknown): string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value) || !value.every(argument => typeof argument === 'string')) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Input "launch_args" must be an array of strings.'
);
}
return value;
}
export function parseOpenUrlInput(value: unknown): string {
const openUrl = parseNonEmptyStringInput(value, 'open_url');
if (!URL.canParse(openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Input "open_url" must be a valid URL.'
);
}
return openUrl;
}
@@ -2,6 +2,7 @@ import { asyncResult } from '@expo/results';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
@@ -34,10 +35,16 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// A `file=` attribute counts as present only when it is a non-empty string.
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
// the same value as a `<property name="file" value="..."/>` child.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
return typeof f === 'string' && f.length > 0 ? f : undefined;
if (typeof f === 'string' && f.length > 0) {
return f;
}
const properties: { '@_name'?: unknown; '@_value'?: unknown }[] = tc?.properties?.property ?? [];
const fileProperty = properties.find(property => property['@_name'] === 'file')?.['@_value'];
return typeof fileProperty === 'string' && fileProperty.length > 0 ? fileProperty : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
@@ -62,26 +69,46 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
continue;
}
// Standard JUnit marks skipped tests with a <skipped/> child (no failure/error). Exclude
// them so they aren't miscounted as passed, matching the report.json path which drops
// skipped flows.
if (tc.skipped != null) {
continue;
}
const file = fileAttrOf(tc);
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// maestro-runner puts the real error in the `message` attribute and the command
// label (e.g. `tapOn`) in the body; official Maestro only writes the body. Prefer
// `@_message` and fall back to `#text` so both stay correct.
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
// a testcase passes when it has no failure or error child.
const statusAttribute = tc['@_status'];
const status: 'passed' | 'failed' =
typeof statusAttribute === 'string'
? statusAttribute === 'SUCCESS'
? 'passed'
: 'failed'
: tc.failure == null && tc.error == null
? 'passed'
: 'failed';
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
@@ -176,6 +203,69 @@ async function fileExists(absPath: string): Promise<boolean> {
return (await asyncResult(fs.stat(absPath))).ok;
}
// maestro-runner writes report.json as the source of truth for a run. Use it for runner control
// flow because sourceFile preserves the exact flow path, while older runner JUnit reports flatten
// it to a basename. Keep only passed and failed flows so the result matches Maestro JUnit reports,
// which do not include skipped flows.
const MaestroRunnerRecordedFlowSchema = z.object({
name: z.string().min(1),
sourceFile: z.string().min(1),
status: z.enum(['passed', 'failed']),
});
const MaestroRunnerReportSchema = z.object({
flows: z
.array(
z.discriminatedUnion('status', [
MaestroRunnerRecordedFlowSchema,
z.object({ status: z.literal('skipped') }),
])
)
.transform(flows =>
flows.filter(
(flow): flow is z.infer<typeof MaestroRunnerRecordedFlowSchema> => flow.status !== 'skipped'
)
),
});
type MaestroRunnerReport = z.infer<typeof MaestroRunnerReportSchema>;
export async function parseFailedFlowsFromMaestroRunnerReport(args: {
reportDirectory: string;
workingDirectory: string;
}): Promise<string[] | null> {
const report = await parseMaestroRunnerReport(args.reportDirectory);
if (report === null) {
return null;
}
const failedPaths = [
...new Set(report.flows.filter(flow => flow.status === 'failed').map(flow => flow.sourceFile)),
];
if (failedPaths.length === 0) {
return null;
}
for (const flowPath of failedPaths) {
if (!(await fileExists(path.resolve(args.workingDirectory, flowPath)))) {
return null;
}
}
return failedPaths;
}
export async function parseMaestroRunnerReport(
reportDirectory: string
): Promise<MaestroRunnerReport | null> {
let report: unknown;
try {
report = JSON.parse(await fs.readFile(path.join(reportDirectory, 'report.json'), 'utf8'));
} catch {
return null;
}
const result = MaestroRunnerReportSchema.safeParse(report);
return result.success ? result.data : null;
}
// Group by `file=` so two same-named flows in different files stay separate.
export async function parseMaestroResultsFromFileAttrs(
junitDirectory: string
@@ -2,6 +2,9 @@ import { bunyan } from '@expo/logger';
import { type Dirent } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { Sentry } from '../../sentry';
export interface ParsedFailureScreenshot {
flowName: string;
@@ -133,6 +136,156 @@ export async function harvestFailureScreenshotsAsync(args: {
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
}
const MaestroRunnerCommandSchema = z.object({
status: z.string().optional(),
artifacts: z
.object({
screenshotBefore: z.string().optional(),
screenshotAfter: z.string().optional(),
})
.optional(),
get subCommands() {
return z.array(MaestroRunnerCommandSchema).optional();
},
});
type MaestroRunnerCommand = z.infer<typeof MaestroRunnerCommandSchema>;
const MaestroRunnerFlowDetailSchema = z.object({
commands: z.array(MaestroRunnerCommandSchema).optional(),
});
const MaestroRunnerScreenshotReportSchema = z.object({
flows: z
.array(
z.object({
name: z.string().optional(),
status: z.string().optional(),
dataFile: z.string().optional(),
})
)
.optional(),
});
// maestro-runner records failure screenshot paths in its report JSON instead of using the
// official Maestro debug-directory layout. Never throws, so screenshots cannot change the test
// result.
export async function harvestMaestroRunnerFailureScreenshotsAsync(args: {
reportDirectory: string;
capturedSinceMs: number;
attemptIndex: number;
logger: bunyan;
}): Promise<HarvestedScreenshot[]> {
let report: unknown;
try {
report = JSON.parse(await fs.readFile(path.join(args.reportDirectory, 'report.json'), 'utf8'));
} catch (err: any) {
args.logger.info(
{ err },
`Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`
);
return [];
}
const parsedReport = MaestroRunnerScreenshotReportSchema.safeParse(report);
if (!parsedReport.success) {
args.logger.warn(
{ err: parsedReport.error },
`Skipping maestro-runner screenshot harvest: unexpected report.json shape in ${args.reportDirectory}.`
);
Sentry.capture('maestro-runner report.json failed schema validation', parsedReport.error);
return [];
}
const flows = parsedReport.data.flows ?? [];
const shots: HarvestedScreenshot[] = [];
for (const { name, status, dataFile } of flows) {
if (status !== 'failed' || !name || !dataFile) {
continue;
}
const flowDataPath = resolvePathInsideDirectory(args.reportDirectory, dataFile);
if (!flowDataPath) {
args.logger.info(`Skipping maestro-runner flow data outside the report directory.`);
continue;
}
let screenshotPath: string | undefined;
try {
const detail = MaestroRunnerFlowDetailSchema.safeParse(
JSON.parse(await fs.readFile(flowDataPath, 'utf8'))
);
if (!detail.success) {
args.logger.warn(
{ err: detail.error },
`Skipping malformed maestro-runner flow data ${flowDataPath}.`
);
Sentry.capture('maestro-runner flow data failed schema validation', detail.error);
continue;
}
screenshotPath = findFailedMaestroRunnerScreenshot(detail.data.commands ?? []);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable maestro-runner flow data ${flowDataPath}.`);
continue;
}
if (!screenshotPath) {
continue;
}
const fileAbsPath = resolvePathInsideDirectory(args.reportDirectory, screenshotPath);
if (!fileAbsPath) {
args.logger.info(`Skipping maestro-runner screenshot outside the report directory.`);
continue;
}
let capturedAtMs: number;
try {
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
continue;
}
if (capturedAtMs < args.capturedSinceMs) {
continue;
}
const flowName = normalizeFlowName(name);
shots.push({
fileAbsPath,
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex: args.attemptIndex,
capturedAtMs,
},
});
}
return shots;
}
function findFailedMaestroRunnerScreenshot(
commands: readonly MaestroRunnerCommand[]
): string | undefined {
for (const command of commands) {
if (command.status !== 'failed') {
continue;
}
const nested = findFailedMaestroRunnerScreenshot(command.subCommands ?? []);
const screenshot =
nested ?? command.artifacts?.screenshotAfter ?? command.artifacts?.screenshotBefore;
if (screenshot) {
return screenshot;
}
}
return undefined;
}
function resolvePathInsideDirectory(directory: string, relativePath: string): string | null {
const candidate = path.resolve(directory, relativePath);
const relative = path.relative(directory, candidate);
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
? candidate
: null;
}
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
@@ -13,6 +13,7 @@ import os from 'os';
import path from 'path';
import { z } from 'zod';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
@@ -21,12 +22,15 @@ import {
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseFailedFlowsFromMaestroRunnerReport,
parseJUnitTestCases,
parseMaestroRunnerReport,
} from './maestroResultParser';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
harvestMaestroRunnerFailureScreenshotsAsync,
selectFailureScreenshots,
} from './maestroScreenshots';
import { CustomBuildContext } from '../../customBuildContext';
@@ -62,35 +66,63 @@ function isFilesystemError(err: any): boolean {
);
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
function buildMaestroArgs({
backend,
platform,
flowPaths,
output,
outputFormat,
shards,
includeTags,
excludeTags,
}: {
backend: MaestroBackend;
platform: 'ios' | 'android';
flowPaths: string[];
output: string | null;
outputFormat: string | undefined;
shards: number | undefined;
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
includeTags: string | undefined;
excludeTags: string | undefined;
}): { executable: MaestroBackend; args: string[] } {
switch (backend) {
case 'maestro': {
const args = ['test'];
if (outputFormat) {
args.push(`--format=${outputFormat.toUpperCase()}`);
}
if (output) {
args.push(`--output=${output}`);
}
if (shards !== undefined) {
args.push(`--shard-split=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
if (excludeTags) {
args.push(`--exclude-tags=${excludeTags}`);
}
args.push(...flowPaths);
return { executable: 'maestro', args };
}
case 'maestro-runner': {
// The runner writes every report format to one directory. output_format only
// selects which extra report artifact EAS uploads after the test.
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
if (excludeTags) {
args.push(`--exclude-tags=${excludeTags}`);
}
args.push(...flowPaths);
return { executable: 'maestro-runner', args };
}
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
@@ -149,6 +181,11 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'backend',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
@@ -178,23 +215,38 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
// Publish known paths before backend validation so `if: always()` upload
// steps can use them after an early failure. final_report_path names the
// report selected by output_format, not every report the backend produces.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
if (outputFormat === 'junit') {
outputs.final_report_path.set(junitFinalReportPath);
}
// Resolved after the output assignments above: an invalid backend input or
// EAS_MAESTRO_BACKEND throws, and downstream `if: always()` upload steps still
// need the outputs interpolated.
const backend = resolveMaestroBackend({
input: inputs.backend.value,
env,
});
// The runner can write JUnit regardless of output_format; Maestro CLI only
// writes it when JUnit is selected. Either command may fail before it writes a file.
const mayHaveJUnitReports = backend === 'maestro-runner' || outputFormat === 'junit';
const maestroCliOutputPath =
backend === 'maestro' && outputFormat && outputFormat !== 'junit'
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
outputs.final_report_path.set(maestroCliOutputPath);
}
const flowPaths = parseInput(
@@ -219,6 +271,16 @@ 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' &&
outputFormat !== undefined &&
!['junit', 'html', 'allure'].includes(outputFormat)
) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
);
}
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
@@ -227,7 +289,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// flow scan is built lazily in the retry branch below and memoized so
// retries share one scan. Never runs when the report has `file=`.
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
@@ -237,15 +299,23 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// flows. The failed-flow parsers return null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
// Retry-failed-only: after a failed attempt, subset to the failing flows. The
// failed-flow parsers return null when a report cannot be trusted; we then fall
// through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const harvested: HarvestedScreenshot[] = [];
const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
const totalAttempts = retries + 1;
if (platform === 'android' && androidConnectionMode === 'dadb') {
if (
backend === 'maestro-runner' &&
platform === 'android' &&
androidConnectionMode === 'dadb'
) {
logger.info('maestro-runner does not support DADB. Using the default ADB connection.');
}
if (backend === 'maestro' && platform === 'android' && androidConnectionMode === 'dadb') {
try {
const adbOverrideDirectoryPath = await fs.mkdtemp(
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
@@ -280,29 +350,53 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
// maestro-runner writes all report formats and screenshot metadata here.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
);
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: maestroCliOutputPath;
const { executable, args: maestroArgs } = buildMaestroArgs({
backend,
platform,
flowPaths: flowsToRun,
output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
includeTags,
excludeTags,
});
logger.info(
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`
);
// The runner output directory is deterministic and can survive a prior run; clear it
// best-effort so a crash before it writes fresh output can't resurrect stale results.
if (backend === 'maestro-runner') {
try {
await fs.rm(runnerOutputDirectory, { recursive: true, force: true });
} catch (err) {
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
}
}
// Maestro CLI uses the same HTML path for every retry. Remove the previous report
// before spawning so an attempt that fails before report generation cannot upload it.
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
try {
await fs.rm(maestroCliOutputPath, { force: true });
} catch (err) {
throw new SystemError('Failed to clear the previous Maestro HTML report', {
cause: err,
});
}
}
const attemptStartedAtMs = Date.now();
try {
await spawn('maestro', maestroArgs, {
await spawn(executable, maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
@@ -311,56 +405,107 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
throw new SystemError(`Failed to invoke ${executable}`, { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
throw new SystemError(`Unexpected spawn failure invoking ${executable}`, {
cause: err,
});
}
}
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
// maestro-runner writes a report directory. Copy its JUnit file into the existing
// per-attempt directory so retry parsing and final report merging remain shared.
if (backend === 'maestro-runner' && outputPath) {
try {
await fs.copyFile(path.join(runnerOutputDirectory, 'junit-report.xml'), outputPath);
} catch (err: any) {
logger.warn(
{ err },
`Failed to collect the maestro-runner JUnit report for attempt ${attempt + 1}.`
);
}
}
if (backend === 'maestro-runner') {
reportDirectories.push(runnerOutputDirectory);
}
// The pre-packaged job reports test cases only for JUnit (the default format).
// Individual screenshots need those test cases to be visible on the website.
if (outputFormat === 'junit') {
const failedFlowNames = outputPath
? await parseFailedFlowNamesFromJUnitFile(outputPath)
: new Set<string>();
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
failedFlowNames,
logger,
}))
);
let screenshots: HarvestedScreenshot[];
switch (backend) {
case 'maestro': {
const failedFlowNames = outputPath
? await parseFailedFlowNamesFromJUnitFile(outputPath)
: new Set<string>();
screenshots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
failedFlowNames,
logger,
});
break;
}
case 'maestro-runner':
screenshots = await harvestMaestroRunnerFailureScreenshotsAsync({
reportDirectory: runnerOutputDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
logger,
});
break;
}
harvested.push(...screenshots);
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
if (
retryFailedOnly &&
(backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))
) {
let failed: string[] | null;
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
} else {
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
: null;
switch (backend) {
case 'maestro-runner':
failed = await parseFailedFlowsFromMaestroRunnerReport({
reportDirectory: runnerOutputDirectory,
workingDirectory: stepCtx.workingDirectory,
});
break;
case 'maestro':
if (!outputPath) {
failed = null;
break;
}
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
break;
}
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
})
: null;
break;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
@@ -379,14 +524,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
await sleepAsync(2000);
}
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Only JUnit is merged into final_report_path. Runner HTML/Allure keeps its
// per-attempt JUnit files, but final_report_path names the selected report.
// On data errors (bad XML, missing input), fall back to copy-latest.
// Filesystem errors short-circuit straight to SystemError.
if (finalReportPath !== undefined) {
if (outputFormat === 'junit') {
try {
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
outputPath: junitFinalReportPath,
});
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
@@ -396,7 +542,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
outputPath: junitFinalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
@@ -404,16 +550,96 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
// Upload before the failure verdict so fully-failed runs still get screenshots.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
await uploadFailureScreenshotsAsync({
harvested,
backend,
reportDirectories,
ctx,
logger,
});
}
// Upload the selected non-JUnit report before the failure verdict. The pre-packaged
// job also uploads testsDirectory, but a named artifact makes the report easy to find.
let selectedReport:
| { name: string; artifactPath: string; finalReportPath: string }
| undefined;
switch (outputFormat) {
case 'html': {
switch (backend) {
case 'maestro': {
if (!maestroCliOutputPath) {
break;
}
selectedReport = {
name: 'Maestro HTML Report',
artifactPath: maestroCliOutputPath,
finalReportPath: maestroCliOutputPath,
};
break;
}
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
// HTML references nearby screenshots, so keep its whole attempt directory.
selectedReport = {
name: 'Maestro Runner HTML Report',
artifactPath: reportDirectory,
finalReportPath: path.join(reportDirectory, 'report.html'),
};
break;
}
}
break;
}
case 'allure': {
switch (backend) {
case 'maestro':
logger.warn(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
break;
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
const allureResultsDirectory = path.join(reportDirectory, 'allure-results');
selectedReport = {
name: 'Maestro Runner Allure Results',
artifactPath: allureResultsDirectory,
finalReportPath: allureResultsDirectory,
};
break;
}
}
break;
}
}
if (selectedReport) {
outputs.final_report_path.set(selectedReport.finalReportPath);
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
logger,
});
} catch (err: any) {
logger.warn({ err }, `Failed to upload ${selectedReport.name}.`);
}
}
// The retry loop exits via success (0), numeric status (retryable),
@@ -434,12 +660,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// the maestro test result. Caller guards on junit (harvest only runs for junit).
async function uploadFailureScreenshotsAsync({
harvested,
junitReportDirectory,
backend,
reportDirectories,
ctx,
logger,
}: {
harvested: HarvestedScreenshot[];
junitReportDirectory: string;
backend: MaestroBackend;
reportDirectories: string[];
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
@@ -449,9 +677,25 @@ async function uploadFailureScreenshotsAsync({
// test verdict (the whole step is verdict-neutral for screenshots).
let selected: HarvestedScreenshot[];
try {
const pureFailureFlowNames = computePureFailureFlowNames(
await parseJUnitTestCases(junitReportDirectory)
);
let flowResults: { name: string; status: 'passed' | 'failed' }[];
switch (backend) {
case 'maestro':
flowResults = (
await Promise.all(reportDirectories.map(directory => parseJUnitTestCases(directory)))
).flat();
break;
case 'maestro-runner': {
const results = await Promise.all(
reportDirectories.map(directory => parseMaestroRunnerReport(directory))
);
// Use whichever reports parsed. An unreadable report (e.g. a final retry that crashed
// before writing report.json) contributes no flows rather than discarding screenshots
// harvested from the attempts that did report — mirroring the maestro path above.
flowResults = results.flatMap(result => result?.flows ?? []);
break;
}
}
const pureFailureFlowNames = computePureFailureFlowNames(flowResults);
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
} catch (err: any) {
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
@@ -16,6 +16,10 @@ export type IpaInfo = {
bundleIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
// `DTPlatformName` from the bundle's Info.plist (e.g. `iphoneos`, `appletvos`,
// `macosx`, `xros`), or null when absent. Used to pick the App Store Connect
// platform for the upload. See `AscApiUtils.ascPlatformFromDtPlatformName`.
dtPlatformName: string | null;
};
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
@@ -91,10 +95,14 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
);
}
const dtPlatformName =
typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
return {
bundleIdentifier,
bundleShortVersion,
bundleVersion,
dtPlatformName,
};
} catch (error) {
if (error instanceof UserError) {
@@ -62,6 +62,12 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_backend',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
allowedValues: ['fastlane', 'zsign'],
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -144,12 +150,14 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
backend: inputs.ios_signing_backend.value as 'fastlane' | 'zsign' | undefined,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
tmpDir,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -297,24 +305,51 @@ export async function resolveAndroidSigningOptionsAsync({
}
/**
* Resolves iOS signing options from the job secrets.
* Resolves iOS signing options from the job secrets, dispatching on the
* requested signing backend.
*/
export async function resolveIosSigningOptionsAsync({
job,
logger,
backend,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
job: Job;
logger: bunyan;
backend?: 'fastlane' | 'zsign';
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
if (iosJob.simulator || buildCredentials == null) {
return undefined;
}
const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
return backend === 'zsign'
? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
: await createIosFastlaneOptionsAsync(commonOptions);
}
/**
* Creates signing options for the fastlane backend: certificates are imported
* into a temporary keychain and provisioning profiles are parsed with the
* macOS `security` tool.
*/
async function createIosFastlaneOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions> {
const credentialsManager = new IosCredentialsManager(buildCredentials);
const credentials = await credentialsManager.prepare(logger);
@@ -330,3 +365,53 @@ export async function resolveIosSigningOptionsAsync({
entitlementsPath,
};
}
/**
* Creates signing options for the zsign backend. The distribution certificate
* secret is already a PKCS#12 file, so it goes to disk as-is together with the
* provisioning profiles.
*/
async function createIosZsignOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions> {
const targets = Object.entries(buildCredentials);
const [targetName, targetCredentials] = targets[0];
logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
const certificatePath = path.join(tmpDir, `dist-cert-${randomUUID()}.p12`);
await fs.promises.writeFile(
certificatePath,
new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64'))
);
// zsign matches profiles to bundles by the app-id suffix itself, so the
// record keys are informational only.
const provisioningProfile: Record<string, string> = {};
for (const [target, credentials] of targets) {
const profilePath = path.join(tmpDir, `profile-${target}-${randomUUID()}.mobileprovision`);
await fs.promises.writeFile(
profilePath,
new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64'))
);
provisioningProfile[target] = profilePath;
}
return {
backend: 'zsign',
certificatePath,
keyPassword: targetCredentials.distributionCertificate.password,
provisioningProfile,
useAppEntitlements,
entitlementsPath,
};
}
@@ -15,10 +15,16 @@ import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
import {
CACHE_KEY_PREFIX_BY_PLATFORM,
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcacheKeyPrefix,
getCcachePath,
} from '../../utils/cacheKey';
import {
getCocoapodsCachePaths,
resolveCocoapodsCacheKeyAsync,
restoreCocoapodsCacheArchiveAsync,
} from '../../utils/cocoapodsCache';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -35,6 +41,11 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -48,10 +59,20 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
@@ -63,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
} else {
await restoreCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
@@ -92,13 +120,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
export async function restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
@@ -136,7 +164,7 @@ export async function restoreCcacheAsync({
})
);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Restoring cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -147,8 +175,8 @@ export async function restoreCcacheAsync({
robotAccessToken,
paths: [cachePath],
key: cacheKey,
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
platform,
keyPrefixes: [getCcacheKeyPrefix(target)],
platform: target.platform,
});
await decompressCacheAsync({
@@ -175,7 +203,7 @@ export async function restoreCcacheAsync({
expoApiServerURL,
robotAccessToken,
paths: [cachePath],
platform,
platform: target.platform,
});
await decompressCacheAsync({
archivePath,
@@ -190,6 +218,67 @@ export async function restoreCcacheAsync({
}
}
export async function restoreCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_PODS_CACHE !== '1') {
return;
}
try {
const { stdout } = await spawnAsync('pod', ['--version'], {
env,
stdio: 'pipe',
});
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
logger.info(`Restoring CocoaPods cache key: ${key}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [podsDirectory],
key,
keyPrefixes: [keyPrefix],
platform: Platform.IOS,
});
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
logger.info(
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
Datadog.log(`CocoaPods cache restored (${hitType})`, {
event: 'cocoapods_cache_restored',
cache_hit_type: hitType,
});
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No CocoaPods cache found for this key');
} else {
logger.warn('Failed to restore CocoaPods cache: ', err);
}
}
}
export async function restoreGradleCacheAsync({
logger,
workingDirectory,
@@ -258,13 +347,14 @@ export async function restoreGradleCacheAsync({
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const journalPath = path.join(gradleCachesPath, 'journal-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
@@ -15,7 +15,16 @@ import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import {
compressCocoapodsCacheAsync,
getCocoapodsCachePaths,
resolveCocoapodsCacheKeyAsync,
} from '../../utils/cocoapodsCache';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
@@ -30,6 +39,11 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -43,10 +57,20 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
@@ -59,22 +83,88 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
} else {
await saveCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
export async function saveCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_PODS_CACHE !== '1') {
return;
}
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
try {
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
} catch {
logger.warn('No CocoaPods installation found, skipping cache save');
return;
}
try {
const { stdout } = await spawnAsync('pod', ['--version'], {
env,
stdio: 'pipe',
});
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
logger.info(`Saving CocoaPods cache key: ${key}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing CocoaPods cache...');
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
const { size } = await fs.promises.stat(archivePath);
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
paths: [podsDirectory],
size,
platform: Platform.IOS,
});
} catch (err) {
logger.error({ err }, 'Failed to save CocoaPods cache');
}
}
export async function saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
evictUsedBefore: Date;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
@@ -99,7 +189,7 @@ export async function saveCcacheAsync({
}
try {
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Saving cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -143,7 +233,7 @@ export async function saveCcacheAsync({
key: cacheKey,
paths: [cachePath],
size,
platform,
platform: target.platform,
});
} catch (err) {
logger.error({ err }, 'Failed to save cache');
@@ -167,6 +257,7 @@ export async function saveGradleCacheAsync({
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const journalPath = path.join(gradleCachesPath, 'journal-1');
try {
await fs.promises.access(buildCachePath);
@@ -186,9 +277,11 @@ export async function saveGradleCacheAsync({
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
await fs.promises.mkdir(journalPath, { recursive: true });
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
@@ -204,7 +297,7 @@ export async function saveGradleCacheAsync({
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
size,
platform: Platform.ANDROID,
});
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
archivePath,
size,
platform,
force = false,
}: {
logger: bunyan;
jobId: string;
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
archivePath: string;
size: number;
platform: Platform | undefined;
force?: boolean;
}): Promise<void> {
const routerURL = platform
? 'v2/turtle-builds/caches/upload-sessions'
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
key,
version: getCacheVersion(paths),
size,
force,
})
: JSON.stringify({
jobRunId: jobId,
key,
version: getCacheVersion(paths),
size,
force,
}),
headers: {
Authorization: `Bearer ${robotAccessToken}`,
@@ -348,7 +352,10 @@ export async function compressCacheAsync({
for (const { absolutePath, archivePath: targetRelativePath } of allFiles) {
const targetPath = path.join(tempDir, targetRelativePath);
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
// We want to keep source timestamps since Gradle may check them when pruning cache.
const { atime, mtime } = await fs.promises.stat(absolutePath);
await fs.promises.copyFile(absolutePath, targetPath);
await fs.promises.utimes(targetPath, atime, mtime);
if (verbose) {
logger.info(`- ${targetRelativePath}`);
@@ -13,19 +13,37 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
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 {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type StartupTasks, createStartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
@@ -39,6 +57,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(
@@ -50,6 +75,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -60,119 +87,357 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env vars are missing.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
logger.info('Launching agent-device daemon.');
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
const agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
// Earlier steps booted the device and installed and launched the app.
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks: createStartupTasks(logger),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
await daemonProcess.stopAsync();
}
},
}),
});
}
export type AgentDeviceRemoteSessionEnv = {
deviceRunSessionId: string;
ngrokTunnelDomain: string;
ngrokAuthtoken: string;
};
/**
* Reads the env vars that the API server injects into a device run session job:
* DEVICE_RUN_SESSION_ID (to report the remote config back to the API server),
* EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain for our ngrok tunnels), and
* NGROK_AUTHTOKEN (to authenticate them).
*/
export function getAgentDeviceRemoteSessionEnvOrThrow(
env: BuildStepEnv
): AgentDeviceRemoteSessionEnv {
return {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
ngrokTunnelDomain: getNgrokTunnelDomainOrThrow(env),
ngrokAuthtoken: getNgrokAuthtokenOrThrow(env),
};
}
/**
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
{
env,
logger,
signal,
runtimePlatform,
sessionEnv: { deviceRunSessionId, ngrokTunnelDomain, ngrokAuthtoken },
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks,
device,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
runtimePlatform: BuildRuntimePlatform;
sessionEnv: AgentDeviceRemoteSessionEnv;
packageVersion: string | undefined;
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
launch: ReturnType<typeof parseServeSimLaunchInputs>;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
): Promise<void> {
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
let daemonProcess: DetachedProcessHandle | undefined;
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
env,
logger: taskLogger,
signal: tasks.signal,
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
taskLogger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
return webPreview;
});
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
}
);
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
[
'agent-device daemon',
startedDaemon &&
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await startedDaemon.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
}): 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, signal });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
signal?.throwIfAborted();
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 });
// An abort is not an install problem: stop instead of falling back to git.
if (signal?.aborted) {
throw 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 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, signal });
}
}
export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
@@ -195,85 +460,37 @@ 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,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
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,
signal,
});
signal?.throwIfAborted();
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'],
@@ -286,15 +503,18 @@ async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
signal,
});
}
@@ -336,11 +556,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',
@@ -350,11 +568,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 (
@@ -0,0 +1,272 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceName,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
import {
launchApplicationAsync,
parseLaunchArgsInput,
parseOpenUrlInput,
} from './launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It replaces eas/start_ios_simulator or eas/start_android_emulator, eas/download_build,
* eas/install_build, eas/launch_application and eas/start_agent_device_remote_session,
* so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_session',
name: 'Start agent-device session',
__metricsId: 'eas/start_agent_device_session',
inputProviders: [
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'system_image_package',
required: false,
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'lcd_width',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_height',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_density',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
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,
}),
// Session.
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture.value,
networkCaptureFields: inputs.network_capture_fields.value,
},
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
// The app is launched with simctl / adb above, not by serve-sim.
launch: parseServeSimLaunchInputs({}, { runtimePlatform }),
capture,
tasks,
device: { booted, ready },
});
}),
});
}
Loaded 100 of 618 files, more files were not shown because too many files have changed in this diff. Show more