Compare commits

..
Author SHA1 Message Date
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
292 changed files with 22274 additions and 3541 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
+4 -15
View File
@@ -37,20 +37,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
@@ -81,10 +67,13 @@ jobs:
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
cli-versions.json > cli-versions.json.tmp
mv cli-versions.json.tmp cli-versions.json
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $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 commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
git push
- name: Prepare changelog for Slack
run: |
@@ -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
+122 -2
View File
@@ -8,16 +8,135 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🐛 Bug fixes
### 🧹 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))
### 🐛 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))
### 🐛 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 +198,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))
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "21.8.0",
"PRODUCTION": "21.5.0"
"STAGING": "23.1.0",
"PRODUCTION": "23.1.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.8.0",
"version": "23.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
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",
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.8.0",
"version": "23.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,6 +25,7 @@
"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",
"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,22 +39,23 @@
"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": "23.0.0",
"@expo/eas-build-job": "23.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/logger": "23.1.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": "23.1.0",
"@expo/template-file": "23.0.0",
"@expo/turtle-spawn": "23.1.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",
@@ -77,7 +79,8 @@
"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",
@@ -89,7 +92,6 @@
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"bunyan": "^1.8.15",
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
@@ -1,296 +0,0 @@
#include <dlfcn.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* Xcode loads one plugin that implements the llcas_* API. This small shim loads
* Apple's local CAS plugin and forwards every API call to it. The only exception
* is the "remote-service-path" option: Xcode needs that option to schedule C and
* Objective-C cache tasks, but Apple's local plugin does not need a remote service.
* The shim accepts and discards that option. No cache data leaves the machine.
*/
typedef void *llcas_cas_options_t;
typedef void *llcas_cas_t;
typedef void *llcas_cancellable_t;
typedef uint32_t llcas_lookup_result_t;
typedef struct {
const uint8_t *data;
size_t size;
} llcas_digest_t;
typedef struct {
const void *data;
size_t size;
} llcas_data_t;
typedef struct {
uint64_t opaque;
} llcas_objectid_t;
typedef struct {
uint64_t opaque;
} llcas_loaded_object_t;
typedef struct {
uint64_t opaque_b;
uint64_t opaque_e;
} llcas_object_refs_t;
typedef void (*llcas_cas_load_object_cb)(void *, llcas_lookup_result_t,
llcas_loaded_object_t, char *);
typedef void (*llcas_actioncache_get_cb)(void *, llcas_lookup_result_t,
llcas_objectid_t, char *);
typedef void (*llcas_actioncache_put_cb)(void *, bool, char *);
static void *apple_plugin_handle;
static void *apple_plugin_symbol(const char *name) {
if (apple_plugin_handle == NULL) {
const char *path = getenv("EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN");
if (path == NULL || path[0] == '\0') {
path = "/Applications/Xcode.app/Contents/Developer/usr/lib/"
"libToolchainCASPlugin.dylib";
}
apple_plugin_handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (apple_plugin_handle == NULL) {
fprintf(stderr, "EAS local CAS plugin could not load %s: %s\n", path,
dlerror());
abort();
}
}
void *symbol = dlsym(apple_plugin_handle, name);
if (symbol == NULL) {
fprintf(stderr, "EAS local CAS plugin could not find %s: %s\n", name,
dlerror());
abort();
}
return symbol;
}
#define APPLE_PLUGIN(name, type) ((type)apple_plugin_symbol(#name))
void llcas_get_plugin_version(uint32_t *major, uint32_t *minor) {
typedef void (*fn_t)(uint32_t *, uint32_t *);
APPLE_PLUGIN(llcas_get_plugin_version, fn_t)(major, minor);
}
void llcas_string_dispose(char *value) {
typedef void (*fn_t)(char *);
APPLE_PLUGIN(llcas_string_dispose, fn_t)(value);
}
void llcas_cancellable_cancel(llcas_cancellable_t token) {
typedef void (*fn_t)(llcas_cancellable_t);
APPLE_PLUGIN(llcas_cancellable_cancel, fn_t)(token);
}
void llcas_cancellable_dispose(llcas_cancellable_t token) {
typedef void (*fn_t)(llcas_cancellable_t);
APPLE_PLUGIN(llcas_cancellable_dispose, fn_t)(token);
}
llcas_cas_options_t llcas_cas_options_create(void) {
typedef llcas_cas_options_t (*fn_t)(void);
return APPLE_PLUGIN(llcas_cas_options_create, fn_t)();
}
void llcas_cas_options_dispose(llcas_cas_options_t options) {
typedef void (*fn_t)(llcas_cas_options_t);
APPLE_PLUGIN(llcas_cas_options_dispose, fn_t)(options);
}
void llcas_cas_options_set_client_version(llcas_cas_options_t options,
uint32_t major, uint32_t minor) {
typedef void (*fn_t)(llcas_cas_options_t, uint32_t, uint32_t);
APPLE_PLUGIN(llcas_cas_options_set_client_version, fn_t)(options, major, minor);
}
void llcas_cas_options_set_ondisk_path(llcas_cas_options_t options,
const char *path) {
typedef void (*fn_t)(llcas_cas_options_t, const char *);
APPLE_PLUGIN(llcas_cas_options_set_ondisk_path, fn_t)(options, path);
}
bool llcas_cas_options_set_option(llcas_cas_options_t options, const char *name,
const char *value, char **error) {
if (name != NULL && strcmp(name, "remote-service-path") == 0) {
if (error != NULL) {
*error = NULL;
}
return false;
}
typedef bool (*fn_t)(llcas_cas_options_t, const char *, const char *,
char **);
return APPLE_PLUGIN(llcas_cas_options_set_option, fn_t)(options, name, value,
error);
}
llcas_cas_t llcas_cas_create(llcas_cas_options_t options, char **error) {
typedef llcas_cas_t (*fn_t)(llcas_cas_options_t, char **);
return APPLE_PLUGIN(llcas_cas_create, fn_t)(options, error);
}
void llcas_cas_dispose(llcas_cas_t cas) {
typedef void (*fn_t)(llcas_cas_t);
APPLE_PLUGIN(llcas_cas_dispose, fn_t)(cas);
}
int64_t llcas_cas_get_ondisk_size(llcas_cas_t cas, char **error) {
typedef int64_t (*fn_t)(llcas_cas_t, char **);
return APPLE_PLUGIN(llcas_cas_get_ondisk_size, fn_t)(cas, error);
}
bool llcas_cas_set_ondisk_size_limit(llcas_cas_t cas, int64_t size,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, int64_t, char **);
return APPLE_PLUGIN(llcas_cas_set_ondisk_size_limit, fn_t)(cas, size, error);
}
bool llcas_cas_prune_ondisk_data(llcas_cas_t cas, char **error) {
typedef bool (*fn_t)(llcas_cas_t, char **);
return APPLE_PLUGIN(llcas_cas_prune_ondisk_data, fn_t)(cas, error);
}
char *llcas_cas_get_hash_schema_name(llcas_cas_t cas) {
typedef char *(*fn_t)(llcas_cas_t);
return APPLE_PLUGIN(llcas_cas_get_hash_schema_name, fn_t)(cas);
}
uint32_t llcas_digest_parse(llcas_cas_t cas, const char *printed,
uint8_t *bytes, size_t bytes_size, char **error) {
typedef uint32_t (*fn_t)(llcas_cas_t, const char *, uint8_t *, size_t,
char **);
return APPLE_PLUGIN(llcas_digest_parse, fn_t)(cas, printed, bytes,
bytes_size, error);
}
bool llcas_digest_print(llcas_cas_t cas, llcas_digest_t digest, char **printed,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, char **, char **);
return APPLE_PLUGIN(llcas_digest_print, fn_t)(cas, digest, printed, error);
}
bool llcas_cas_get_objectid(llcas_cas_t cas, llcas_digest_t digest,
llcas_objectid_t *object, char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t *,
char **);
return APPLE_PLUGIN(llcas_cas_get_objectid, fn_t)(cas, digest, object, error);
}
llcas_digest_t llcas_objectid_get_digest(llcas_cas_t cas,
llcas_objectid_t object) {
typedef llcas_digest_t (*fn_t)(llcas_cas_t, llcas_objectid_t);
return APPLE_PLUGIN(llcas_objectid_get_digest, fn_t)(cas, object);
}
llcas_lookup_result_t llcas_cas_contains_object(llcas_cas_t cas,
llcas_objectid_t object,
bool globally, char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t, bool,
char **);
return APPLE_PLUGIN(llcas_cas_contains_object, fn_t)(cas, object, globally,
error);
}
llcas_lookup_result_t llcas_cas_load_object(llcas_cas_t cas,
llcas_objectid_t object,
llcas_loaded_object_t *loaded,
char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t,
llcas_loaded_object_t *, char **);
return APPLE_PLUGIN(llcas_cas_load_object, fn_t)(cas, object, loaded, error);
}
void llcas_cas_load_object_async(llcas_cas_t cas, llcas_objectid_t object,
void *context,
llcas_cas_load_object_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_objectid_t, void *,
llcas_cas_load_object_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_cas_load_object_async,
fn_t)(cas, object, context, callback, cancellable);
}
bool llcas_cas_store_object(llcas_cas_t cas, llcas_data_t data,
const llcas_objectid_t *refs, size_t refs_count,
llcas_objectid_t *stored, char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_data_t, const llcas_objectid_t *,
size_t, llcas_objectid_t *, char **);
return APPLE_PLUGIN(llcas_cas_store_object,
fn_t)(cas, data, refs, refs_count, stored, error);
}
llcas_data_t llcas_loaded_object_get_data(llcas_cas_t cas,
llcas_loaded_object_t object) {
typedef llcas_data_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
return APPLE_PLUGIN(llcas_loaded_object_get_data, fn_t)(cas, object);
}
llcas_object_refs_t llcas_loaded_object_get_refs(llcas_cas_t cas,
llcas_loaded_object_t object) {
typedef llcas_object_refs_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
return APPLE_PLUGIN(llcas_loaded_object_get_refs, fn_t)(cas, object);
}
size_t llcas_object_refs_get_count(llcas_cas_t cas, llcas_object_refs_t refs) {
typedef size_t (*fn_t)(llcas_cas_t, llcas_object_refs_t);
return APPLE_PLUGIN(llcas_object_refs_get_count, fn_t)(cas, refs);
}
llcas_objectid_t llcas_object_refs_get_id(llcas_cas_t cas,
llcas_object_refs_t refs,
size_t index) {
typedef llcas_objectid_t (*fn_t)(llcas_cas_t, llcas_object_refs_t, size_t);
return APPLE_PLUGIN(llcas_object_refs_get_id, fn_t)(cas, refs, index);
}
llcas_lookup_result_t llcas_actioncache_get_for_digest(llcas_cas_t cas,
llcas_digest_t digest,
llcas_objectid_t *result,
bool globally,
char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_digest_t,
llcas_objectid_t *, bool, char **);
return APPLE_PLUGIN(llcas_actioncache_get_for_digest,
fn_t)(cas, digest, result, globally, error);
}
void llcas_actioncache_get_for_digest_async(llcas_cas_t cas,
llcas_digest_t digest,
bool globally, void *context,
llcas_actioncache_get_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, bool, void *,
llcas_actioncache_get_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_actioncache_get_for_digest_async,
fn_t)(cas, digest, globally, context, callback, cancellable);
}
bool llcas_actioncache_put_for_digest(llcas_cas_t cas, llcas_digest_t digest,
llcas_objectid_t value, bool globally,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
char **);
return APPLE_PLUGIN(llcas_actioncache_put_for_digest,
fn_t)(cas, digest, value, globally, error);
}
void llcas_actioncache_put_for_digest_async(llcas_cas_t cas,
llcas_digest_t digest,
llcas_objectid_t value,
bool globally, void *context,
llcas_actioncache_put_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
void *, llcas_actioncache_put_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_actioncache_put_for_digest_async,
fn_t)(cas, digest, value, globally, context, callback,
cancellable);
}
@@ -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 = {
+2 -2
View File
@@ -98,7 +98,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,
});
@@ -238,7 +238,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,
+29 -33
View File
@@ -21,9 +21,9 @@ import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreXcodeCompilationCacheAsync,
restoreCocoapodsCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCompilationCacheAsync } from '../steps/functions/saveBuildCache';
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -104,21 +104,19 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await Promise.all([
restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
}),
restoreXcodeCompilationCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
}),
]);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
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,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
@@ -254,22 +252,20 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.saveCache(ctx);
await Promise.all([
saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
}),
saveXcodeCompilationCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
}),
]);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
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: {
@@ -6,6 +6,7 @@ import {
Platform,
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 +15,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,6 +24,27 @@ jest.mock('@expo/downloader');
jest.mock('@urql/core');
describe('projectSources', () => {
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('should use the refreshed repository URL', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
@@ -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}`);
@@ -110,7 +110,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
});
}
async function unpackTarGzAsync({
export async function unpackTarGzAsync({
logger,
source,
destination,
@@ -122,6 +122,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(
+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';
@@ -1,161 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
decompressXcodeCompilationCacheAsync,
prepareXcodeCompilationCacheEnvAsync,
} from '../compilationCache';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const DEVELOPER_DIRECTORY = '/Applications/Xcode.app/Contents/Developer';
const APPLE_PLUGIN_PATH = path.join(DEVELOPER_DIRECTORY, 'usr/lib/libToolchainCASPlugin.dylib');
const BUNDLED_SHIM_PATH = path.resolve(
__dirname,
'../../../bin/libeas_xcode_local_cas_plugin.dylib'
);
describe(prepareXcodeCompilationCacheEnvAsync, () => {
test('does nothing without the explicit opt-in', async () => {
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {},
logger: createMockLogger(),
});
expect(result).toEqual({});
expect(spawn).not.toHaveBeenCalled();
});
test('enables compilation caching after validating the bundled arm64 shim', async () => {
vol.fromJSON({
[BUNDLED_SHIM_PATH]: 'bundled shim',
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockResolvedValue({} as any);
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'xcrun',
expect.arrayContaining([
'llvm-cas',
`--fcas-plugin-path=${BUNDLED_SHIM_PATH}`,
'--fcas-plugin-option=remote-service-path=/dev/null',
'--ingest',
BUNDLED_SHIM_PATH,
]),
expect.objectContaining({
env: expect.objectContaining({
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
}),
})
);
expect(result).toEqual({
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
GYM_XCARGS: `COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=${BUNDLED_SHIM_PATH} COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null`,
GYM_DERIVED_DATA_PATH: '/project/ios/build',
});
});
test('does not enable compilation caching when the bundled shim is missing', async () => {
const logger = createMockLogger();
vol.fromJSON({
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger,
});
expect(spawn).not.toHaveBeenCalled();
expect(result).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
test('does not enable compilation caching when the bundled shim is incompatible', async () => {
const logger = createMockLogger();
vol.fromJSON({
[BUNDLED_SHIM_PATH]: 'bundled shim',
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockRejectedValue(new Error('incompatible plugin'));
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger,
});
expect(result).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
});
describe('Xcode compilation cache archives', () => {
test('uses system tar to preserve sparse files', async () => {
const workingDirectory = '/working-directory';
vol.fromJSON({
[path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH, 'cache.db')]: 'cache',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockResolvedValue({} as any);
const { archivePath } = await compressXcodeCompilationCacheAsync({
workingDirectory,
env: {},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'tar',
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
expect.any(Object)
);
await decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env: {},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'tar',
['-xzSf', archivePath, '-C', workingDirectory],
expect.any(Object)
);
});
});
@@ -6,7 +6,6 @@ import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import * as CompilationCache from '../compilationCache';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
@@ -24,19 +23,13 @@ const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({
simulator,
env = {},
}: {
simulator: boolean;
env?: Record<string, string>;
}): BuildContext<Ios.Job> {
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
@@ -78,10 +71,6 @@ describe(runFastlaneGym, () => {
(spawn as jest.Mock).mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
{
name: 'archive',
@@ -136,68 +125,4 @@ describe(runFastlaneGym, () => {
workspacePath: IOS_DIR,
});
});
it('enables local Swift, C, and Objective-C compilation caching', async () => {
jest.spyOn(CompilationCache, 'prepareXcodeCompilationCacheEnvAsync').mockResolvedValue({
GYM_XCARGS:
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
});
const ctx = makeIosBuildContext({
simulator: false,
env: {
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
GYM_XCARGS: 'OTHER_SWIFT_FLAGS=-warnings-as-errors',
},
});
await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(spawn).toHaveBeenCalledWith(
'fastlane',
['gym'],
expect.objectContaining({
env: expect.objectContaining({
GYM_XCARGS:
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
}),
})
);
});
it('does not enable compilation caching when the local plugin cannot be prepared', async () => {
const ctx = makeIosBuildContext({
simulator: false,
env: {
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
},
});
await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(spawn).toHaveBeenCalledWith(
'fastlane',
['gym'],
expect.objectContaining({
env: expect.not.objectContaining({ GYM_XCARGS: expect.any(String) }),
})
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
});
@@ -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,194 +0,0 @@
import { Env, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { CACHE_KEY_PREFIX_BY_PLATFORM, generateDefaultBuildCacheKeyAsync } from '../utils/cacheKey';
export const XCODE_COMPILATION_CACHE_ENV = 'EAS_BUILD_XCODE_COMPILATION_CACHE';
export const XCODE_COMPILATION_CACHE_RELATIVE_PATH = 'ios/build/CompilationCache.noindex';
export function getXcodeCompilationCachePath(derivedDataPath: string): string {
return path.join(derivedDataPath, 'CompilationCache.noindex');
}
async function prepareXcodeLocalCompilationCachePluginAsync({
env,
logger,
}: {
env: Env;
logger: bunyan;
}): Promise<{ pluginPath: string; applePluginPath: string }> {
const bundledPluginPath = path.resolve(
__dirname,
'../../bin/libeas_xcode_local_cas_plugin.dylib'
);
const developerDirectory =
env.DEVELOPER_DIR ??
(
await spawn('xcode-select', ['--print-path'], {
env,
logger,
})
).stdout.trim();
const applePluginPath = path.join(
developerDirectory,
'usr',
'lib',
'libToolchainCASPlugin.dylib'
);
if (!(await fs.pathExists(applePluginPath))) {
throw new Error(`The Xcode CAS plugin is missing at ${applePluginPath}.`);
}
if (!(await fs.pathExists(bundledPluginPath))) {
throw new Error(`The EAS local CAS plugin is missing at ${bundledPluginPath}.`);
}
const validationDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-xcode-local-cache-validation-')
);
try {
await spawn(
'xcrun',
[
'llvm-cas',
`--fcas-plugin-path=${bundledPluginPath}`,
'--fcas-plugin-option=remote-service-path=/dev/null',
`--cas=${path.join(validationDirectory, 'cas')}`,
'--ingest',
bundledPluginPath,
],
{
env: { ...env, EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: applePluginPath },
logger,
}
);
} finally {
await fs.remove(validationDirectory);
}
return { pluginPath: bundledPluginPath, applePluginPath };
}
export async function compressXcodeCompilationCacheAsync({
workingDirectory,
env,
logger,
}: {
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<{ archivePath: string }> {
const cachePath = path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH);
if (!(await fs.pathExists(cachePath))) {
throw new Error(`The Xcode compilation cache does not exist at ${cachePath}.`);
}
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'xcode-compilation-cache-'));
const archivePath = path.join(archiveDirectory, 'cache.tar.gz');
await spawn(
'tar',
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
{ env, logger }
);
return { archivePath };
}
export async function decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env,
logger,
}: {
archivePath: string;
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<void> {
await fs.ensureDir(workingDirectory);
await spawn('tar', ['-xzSf', archivePath, '-C', workingDirectory], { env, logger });
}
export async function generateXcodeCompilationCacheKeyAsync({
workingDirectory,
env,
logger,
}: {
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<{ key: string; keyPrefix: string; xcodeVersion: string }> {
const xcodeVersion = (
await spawn('xcodebuild', ['-version'], {
env,
logger,
})
).stdout.trim();
const xcodeVersionHash = createHash('sha256').update(xcodeVersion).digest('hex').slice(0, 16);
const ccacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, Platform.IOS);
const dependencyHash = ccacheKey.slice(CACHE_KEY_PREFIX_BY_PLATFORM[Platform.IOS].length);
const keyPrefix = `ios-xcode-compilation-cache-${xcodeVersionHash}-`;
return {
key: `${keyPrefix}${dependencyHash}`,
keyPrefix,
xcodeVersion: xcodeVersion.replace(/\s+/g, ' '),
};
}
export async function prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env,
logger,
}: {
derivedDataPath: string;
env: Env;
logger: bunyan;
}): Promise<Env> {
if (env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
return {};
}
let plugin: { pluginPath: string; applePluginPath: string };
try {
plugin = await prepareXcodeLocalCompilationCachePluginAsync({
env,
logger,
});
logger.info(`The EAS local Xcode CAS plugin is ready at ${plugin.pluginPath}.`);
} catch (error: any) {
logger.warn(
{ err: error },
'The EAS local Xcode CAS plugin is missing or incompatible. Xcode compilation caching is disabled.'
);
return {};
}
logger.info(
`Xcode compilation caching is enabled. The cache directory is ${getXcodeCompilationCachePath(derivedDataPath)}.`
);
const pluginXcargs = [
'COMPILATION_CACHE_ENABLE_PLUGIN=YES',
`COMPILATION_CACHE_PLUGIN_PATH=${plugin.pluginPath}`,
// A non-empty remote service path makes Xcode schedule C, Objective-C,
// precompiled-module, and precompiled-header cache tasks. The EAS plugin
// consumes this option and never opens the path or performs remote I/O.
'COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
].join(' ');
return {
GYM_XCARGS: [
env.GYM_XCARGS,
'COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES',
pluginXcargs,
]
.filter(Boolean)
.join(' '),
GYM_DERIVED_DATA_PATH: derivedDataPath,
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: plugin.applePluginPath,
};
}
+1 -7
View File
@@ -7,7 +7,6 @@ import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
import * as CompilationCache from './compilationCache';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
@@ -47,18 +46,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
if (ctx.skipNativeBuild) {
throw new SkipNativeBuildError('Skipping fastlane build');
}
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env: ctx.env,
logger: ctx.logger,
});
const buildLogger = new XcodeBuildLogger(ctx.logger, ctx.getReactNativeProjectDirectory());
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv, ...compilationCacheEnv },
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
+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;
}
@@ -19,8 +19,10 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
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';
@@ -45,10 +47,11 @@ import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
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 { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -73,6 +76,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -94,13 +99,14 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
@@ -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,
});
});
});
@@ -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'],
})
);
});
});
@@ -4,8 +4,10 @@ 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';
@@ -19,6 +21,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 +56,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 +114,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 +139,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 +185,128 @@ 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('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 +404,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({
@@ -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,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,171 @@ 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('rejects sharding with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects a non-junit output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner only supports the "junit" output_format'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
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 +624,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())
@@ -1014,6 +1215,29 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('discovers maestro-runner flow results from the attempt report directory', async () => {
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',
output_format: 'junit',
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);
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
dtPlatformName: 'iphoneos',
});
});
@@ -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,86 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
selectXcodeDeveloperDirectoryAsync: 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();
});
});
@@ -14,6 +14,7 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -46,8 +47,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
startServeSimWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -58,6 +59,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 +70,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,
@@ -86,6 +89,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
url: 'https://argent-abc.tunnel.example.com',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -151,5 +158,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,85 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/remoteDeviceRunSession');
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
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 },
},
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(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
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(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
logger,
maxDurationSeconds: 120,
signal: undefined,
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
});
@@ -1,110 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
decompressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../../ios/compilationCache';
import { downloadCacheAsync } from '../restoreCache';
import { restoreXcodeCompilationCacheAsync } from '../restoreBuildCache';
import { uploadCacheAsync } from '../saveCache';
import { saveXcodeCompilationCacheAsync } from '../saveBuildCache';
jest.mock('../restoreCache', () => ({
downloadCacheAsync: jest.fn(),
}));
jest.mock('../saveCache', () => ({
uploadCacheAsync: jest.fn(),
}));
jest.mock('../../../ios/compilationCache', () => ({
XCODE_COMPILATION_CACHE_ENV: 'EAS_BUILD_XCODE_COMPILATION_CACHE',
XCODE_COMPILATION_CACHE_RELATIVE_PATH: 'ios/build/CompilationCache.noindex',
compressXcodeCompilationCacheAsync: jest.fn(),
decompressXcodeCompilationCacheAsync: jest.fn(),
generateXcodeCompilationCacheKeyAsync: jest.fn(),
}));
const WORKING_DIRECTORY = '/working-directory';
const ENV = {
[XCODE_COMPILATION_CACHE_ENV]: '1',
EAS_USE_CACHE: '1',
EAS_BUILD_ID: 'build-id',
__API_SERVER_URL: 'https://api.expo.test',
};
const SECRETS = { robotAccessToken: 'robot-access-token' };
describe('Xcode compilation build cache', () => {
beforeEach(() => {
jest.mocked(generateXcodeCompilationCacheKeyAsync).mockResolvedValue({
key: 'ios-xcode-compilation-cache-version-dependencies',
keyPrefix: 'ios-xcode-compilation-cache-version-',
xcodeVersion: 'Xcode 26.0 Build version 17A000',
});
});
test('restores through the active build cache service', async () => {
jest.mocked(downloadCacheAsync).mockResolvedValue({
archivePath: '/tmp/cache.tar.gz',
matchedKey: 'ios-xcode-compilation-cache-version-dependencies',
});
await restoreXcodeCompilationCacheAsync({
logger: createMockLogger(),
workingDirectory: WORKING_DIRECTORY,
env: ENV,
secrets: SECRETS,
});
expect(downloadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'build-id',
key: 'ios-xcode-compilation-cache-version-dependencies',
keyPrefixes: ['ios-xcode-compilation-cache-version-'],
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
platform: Platform.IOS,
})
);
expect(decompressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: '/tmp/cache.tar.gz',
workingDirectory: WORKING_DIRECTORY,
})
);
});
test('saves through the active build cache service', async () => {
vol.fromJSON({ '/tmp/xcode-cache.tar.gz': 'cache' });
jest.mocked(compressXcodeCompilationCacheAsync).mockResolvedValue({
archivePath: '/tmp/xcode-cache.tar.gz',
});
await saveXcodeCompilationCacheAsync({
logger: createMockLogger(),
workingDirectory: WORKING_DIRECTORY,
env: ENV,
secrets: SECRETS,
});
expect(compressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
workingDirectory: WORKING_DIRECTORY,
})
);
expect(uploadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'build-id',
key: 'ios-xcode-compilation-cache-version-dependencies',
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
platform: Platform.IOS,
size: 5,
})
);
});
});
@@ -8,15 +8,19 @@ 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';
@@ -26,6 +30,10 @@ 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 +57,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({
@@ -70,12 +83,39 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
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,
@@ -115,39 +155,50 @@ 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[];
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions } = 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 });
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,
});
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
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);
await streamPipeline(response.body, fs.createWriteStream(archivePath));
@@ -177,6 +228,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 +252,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,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}.`);
}
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;
}
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;
}
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,58 @@ 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': {
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
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 +176,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 }),
@@ -197,6 +229,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
outputs.final_report_path.set(finalReportPath);
}
// 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,
});
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
@@ -219,6 +259,18 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
);
}
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
);
}
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
@@ -227,7 +279,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 +289,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 +340,45 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
for (let attempt = 0; attempt <= retries; attempt++) {
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
);
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
: backend === 'maestro' && outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
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.`);
}
}
const attemptStartedAtMs = Date.now();
try {
await spawn('maestro', maestroArgs, {
await spawn(executable, maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
@@ -311,56 +387,108 @@ 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,
});
}
}
// 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);
}
// 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.
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;
@@ -413,7 +541,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// 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.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
await uploadFailureScreenshotsAsync({
harvested,
backend,
reportDirectories,
ctx,
logger,
});
}
// The retry loop exits via success (0), numeric status (retryable),
@@ -434,12 +568,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 +585,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,17 +15,17 @@ 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 { Datadog } from '../../datadog';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
decompressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../ios/compilationCache';
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';
@@ -41,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;
@@ -54,91 +59,43 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
await Promise.all([
restoreCcacheAsync({
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,
target,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
logger,
workingDirectory,
platform,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
platform === Platform.IOS
? restoreXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
})
: restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
]);
});
} else {
await restoreCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
export async function restoreXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
const buildCacheEnabled =
env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0');
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
return;
}
try {
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 jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const { key, keyPrefix, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
workingDirectory,
env,
logger,
});
logger.info(`Restoring Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
key,
keyPrefixes: [keyPrefix],
platform: Platform.IOS,
});
await decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env,
logger,
});
logger.info(
`Xcode compilation cache restored ${matchedKey === key ? '(direct hit)' : '(prefix match)'}`
);
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Xcode compilation cache found for this key');
} else {
logger.warn({ err }, 'Failed to restore Xcode compilation cache');
}
}
}
export function createCacheStatsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -163,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> {
@@ -207,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');
@@ -218,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({
@@ -246,7 +203,7 @@ export async function restoreCcacheAsync({
expoApiServerURL,
robotAccessToken,
paths: [cachePath],
platform,
platform: target.platform,
});
await decompressCacheAsync({
archivePath,
@@ -261,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,
@@ -329,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,
@@ -14,14 +14,17 @@ import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../ios/compilationCache';
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 {
@@ -36,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;
@@ -49,34 +57,45 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
await Promise.all([
saveCcacheAsync({
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,
target,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
logger,
workingDirectory,
platform,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
platform === Platform.IOS
? saveXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
})
: saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
]);
});
} else {
await saveCocoapodsCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
export async function saveXcodeCompilationCacheAsync({
export async function saveCocoapodsCacheAsync({
logger,
workingDirectory,
env,
@@ -87,32 +106,37 @@ export async function saveXcodeCompilationCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
const buildCacheEnabled =
env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
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 { key, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
workingDirectory,
const { stdout } = await spawnAsync('pod', ['--version'], {
env,
logger,
stdio: 'pipe',
});
logger.info(`Saving Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
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');
const { archivePath } = await compressXcodeCompilationCacheAsync({
workingDirectory,
env,
logger,
});
logger.info('Compressing CocoaPods cache...');
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
const { size } = await fs.promises.stat(archivePath);
logger.info(`Xcode compilation cache archive size: ${formatBytes(size)}`);
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
@@ -121,26 +145,26 @@ export async function saveXcodeCompilationCacheAsync({
robotAccessToken,
archivePath,
key,
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
paths: [podsDirectory],
size,
platform: Platform.IOS,
});
} catch (err) {
logger.error({ err }, 'Failed to save Xcode compilation cache');
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 };
@@ -165,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');
@@ -209,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');
@@ -233,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);
@@ -252,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,
@@ -270,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}`);
@@ -23,8 +23,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -60,6 +60,11 @@ 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
@@ -73,6 +78,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
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}).`
@@ -101,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | 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}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -124,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -146,6 +149,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -156,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
@@ -0,0 +1,308 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} 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 semver from 'semver';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const AppiumInstalledDriversSchema = z.record(
z.string(),
z.object({ installed: z.boolean().optional() }).passthrough()
);
export function createStartAppiumRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_appium_remote_session',
name: 'Start Appium remote session',
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true }),
],
env: appiumEnv,
});
try {
await waitForAppiumReadyAsync({ appiumProcess, logger });
} catch (error) {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
throw error;
}
const eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
// a single booted Android emulator so the Hub and Appium resolve the same device.
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
}
await eventCollection.stopAsync();
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
},
});
}
export function resolveAppium3VersionSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ?? DEFAULT_APPIUM_VERSION;
const range = semver.validRange(versionSpec);
if (!range || !semver.subset(range, '>=3.0.0 <4.0.0-0')) {
throw new SystemError(
`Appium 3 is required for EAS Simulator sessions. Received package version "${versionSpec}".`
);
}
return versionSpec;
}
type AppiumDevice = {
platformName: 'iOS' | 'Android';
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
udid: string;
};
export async function resolveAppiumDeviceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<AppiumDevice> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN: {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (!bootedDevice) {
throw new SystemError('Could not find a booted iOS simulator for the Appium session.');
}
return {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: bootedDevice.udid,
};
}
case BuildRuntimePlatform.LINUX: {
const attachedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
const bootedDevice = attachedDevices.find(device => device.state === 'device');
if (!bootedDevice) {
throw new SystemError('Could not find a booted Android emulator for the Appium session.');
}
return {
platformName: 'Android',
automationName: 'UiAutomator2',
driverName: 'uiautomator2',
udid: bootedDevice.serialId,
};
}
}
}
async function installAppiumAsync({
versionSpec,
driverName,
env,
logger,
}: {
versionSpec: string;
driverName: AppiumDevice['driverName'];
env: BuildStepEnv;
logger: bunyan;
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
logger.info(`Installing appium@${versionSpec}.`);
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
env: appiumEnv,
logger,
});
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
env: appiumEnv,
stdio: 'pipe',
});
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
}
return { appiumHome, appiumBinPath, appiumEnv };
}
async function waitForAppiumReadyAsync({
appiumProcess,
logger,
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
retries: 0,
logger,
});
if (response.ok) {
return;
}
} catch {}
await sleepAsync(1_000);
}
const output = appiumProcess.getOutput();
throw new SystemError(
`Timed out waiting for Appium to become ready.${output ? `\nAppium output:\n${output}` : ''}`
);
}
@@ -26,8 +26,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -73,6 +73,11 @@ export function createStartArgentRemoteSessionBuildFunction(
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
@@ -86,6 +91,7 @@ export function createStartArgentRemoteSessionBuildFunction(
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;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
@@ -180,7 +186,7 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -193,18 +199,14 @@ export function createStartArgentRemoteSessionBuildFunction(
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -212,7 +214,7 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -221,6 +223,7 @@ export function createStartArgentRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -231,8 +234,8 @@ export function createStartArgentRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
@@ -1,59 +0,0 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
logger.info('Starting serve-sim remote session.');
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
try {
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: serveSim.previewUrl },
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
} finally {
await serveSim.stopAsync();
}
},
});
}
@@ -0,0 +1,83 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${webPreview.previewUrl}`);
try {
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: webPreview.previewUrl },
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
});
} finally {
await webPreview.stopAsync();
}
},
});
}
@@ -115,16 +115,27 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
// Derive the App Store Connect platform from the IPA itself so tvOS (and
// other) binaries are uploaded to the correct version train instead of the
// iOS one. Falls back to `IOS` if the platform cannot be read.
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const platform = ipaInfoResult.ok
? AscApiUtils.ascPlatformFromDtPlatformName(ipaInfoResult.value.dtPlatformName)
: 'IOS';
stepsCtx.logger.info(`Detected App Store Connect platform: ${platform}`);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
});
const buildUploadId = buildUploadResponse.data.id;
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/ios/${buildUploadId}`;
const platformPathSegment = AscApiUtils.testFlightPlatformPathSegment(platform);
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/${platformPathSegment}/${buildUploadId}`;
outputs.build_upload_id.set(buildUploadId);
outputs.build_upload_url.set(buildUploadUrl);
@@ -0,0 +1,38 @@
import { APPIUM_COMMAND_SUMMARIES, humanizeAppiumCommand } from '../appiumCommandSummary';
// Coverage of the generated Appium and driver command sets is enforced at compile time by the
// `Record<AppiumCommand, string>` type in appiumCommandSummary.ts, so it is not
// re-checked here.
describe('APPIUM_COMMAND_SUMMARIES', () => {
it('has a non-empty summary that differs from the command name for every entry', () => {
for (const [command, summary] of Object.entries(APPIUM_COMMAND_SUMMARIES)) {
expect(summary).toBeTruthy();
expect(summary).not.toBe(command);
}
});
});
describe(humanizeAppiumCommand, () => {
it.each([
['getWindowRect', 'Read the screen size'],
['getScreenshot', 'Took a screenshot'],
['getPageSource', 'Read the screen contents'],
['click', 'Tapped an element'],
['setValue', 'Typed into an element'],
['createSession', 'Started the session'],
['deleteSession', 'Ended the session'],
['postAcceptAlert', 'Accepted an alert'],
['mobileScroll', 'Scrolled the screen'],
['openNotifications', 'Opened notifications'],
])('maps the known command %s to a curated summary', (command, expected) => {
expect(humanizeAppiumCommand(command)).toBe(expected);
});
it.each(['proxyReqRes', 'mobile: scroll', 'getDeviceInfo', 'toString', 'constructor', ''])(
'returns the raw command %p when it has no curated summary',
command => {
expect(humanizeAppiumCommand(command)).toBe(command);
}
);
});
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../appiumEvents';
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
jest.mock('../../../utils/turtleFetch');
jest.mock('../deviceRunSessionEvents', () => ({
startDeviceRunSessionEventCollectionAsync: jest.fn(async () => ({
getLastEventObservedAt: () => undefined,
stopAsync: async () => undefined,
})),
}));
describe(startAppiumEventCollectionAsync, () => {
it('writes new Appium Event Timings commands to a normal event file', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(response({ value: [{ id: 'appium-session-id' }] }))
.mockResolvedValueOnce(
response({
value: {
commands: [
{ cmd: 'getOrientation', startTime: 1_786_693_000_000, endTime: 1_786_693_000_025 },
{ cmd: 'getLogEvents', startTime: 1_786_693_000_026, endTime: 1_786_693_000_027 },
],
},
})
)
.mockResolvedValueOnce(response({ value: [] }));
const collection = await startAppiumEventCollectionAsync({
ctx: {} as never,
deviceRunSessionId: 'device-session-id',
appiumUrl: 'http://127.0.0.1:4723/',
logger: { warn: jest.fn() } as never,
pollIntervalMs: 60_000,
});
const source = jest.mocked(startDeviceRunSessionEventCollectionAsync).mock.calls[0][0].source;
const [eventFile] = await source.findEventFilesAsync();
let contents = '';
await waitForAsync(async () => {
contents = await fs.promises.readFile(eventFile, 'utf8');
expect(contents).toContain('getOrientation');
});
const lines = contents.trim().split('\n');
expect(lines).toHaveLength(1);
const [line] = lines;
expect(
source.parseLine({
line,
sourceKey: source.sourceKeyForFile(eventFile),
sequenceNumber: 1,
deviceRunSessionId: 'device-session-id',
})
).toMatchObject({
event: {
producer: 'appium',
type: 'operation.completed',
durationMs: 25,
summary: 'Read the screen orientation',
data: {
command: 'getOrientation',
},
},
});
// Appium 3 removed GET /sessions; session discovery must use /appium/sessions.
expect(turtleFetch).toHaveBeenCalledWith(
'http://127.0.0.1:4723/appium/sessions',
'GET',
expect.anything()
);
expect(turtleFetch).toHaveBeenCalledWith(
'http://127.0.0.1:4723/session/appium-session-id/appium/events',
'POST',
expect.anything()
);
await collection.stopAsync();
});
});
async function waitForAsync(assertion: () => Promise<void>): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
await assertion();
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
await assertion();
}
function response(value: unknown): Awaited<ReturnType<typeof turtleFetch>> {
return { json: async () => value } as never;
}
@@ -67,10 +67,17 @@ describe(listArgentArtifactsAsync, () => {
artifacts: [
{
id: 'artifact-id',
kind: 'native-profile-report',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
{
id: 'legacy-artifact-id',
filename: 'screen.png',
mimeType: 'image/png',
isDirectory: false,
},
],
})
)
@@ -84,10 +91,17 @@ describe(listArgentArtifactsAsync, () => {
expect(artifacts).toEqual([
{
id: 'artifact-id',
kind: 'native-profile-report',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
{
id: 'legacy-artifact-id',
filename: 'screen.png',
mimeType: 'image/png',
isDirectory: false,
},
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer tools-token' },
@@ -143,6 +157,7 @@ describe(uploadArgentArtifactAsync, () => {
async function uploadAsync(artifact: {
id: string;
kind?: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
@@ -185,7 +200,42 @@ describe(uploadArgentArtifactAsync, () => {
);
});
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
it('uses the semantic kind reported by Argent', async () => {
const ctx = await uploadAsync({
id: 'artifact-id',
kind: 'native-profile-report',
filename: 'native-profile-report.md',
mimeType: 'text/markdown',
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'native-profile-report.md',
kind: 'native-profile-report',
})
);
});
it('keeps the semantic kind when a directory is uploaded as a tarball', async () => {
const ctx = await uploadAsync({
id: 'artifact-id',
kind: 'native-profile-trace',
filename: 'native-profile.trace',
mimeType: 'application/octet-stream',
isDirectory: true,
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'native-profile.trace.tar.gz',
kind: 'native-profile-trace',
})
);
});
it('leaves an older unclassified directory artifact without a kind', async () => {
const ctx = await uploadAsync({
id: 'artifact-id',
filename: 'screenshots',
@@ -2,6 +2,10 @@ import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import {
clearTimeout as clearTimeoutCallback,
setTimeout as setTimeoutCallback,
} from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
@@ -9,17 +13,20 @@ import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
ensureFfmpegInstalledAsync,
fetchServeSimTurnArgsAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToServeSimArgs,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForServeSimReadyAsync,
waitForWebPreviewReadyAsync,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('node:timers');
jest.mock('node:timers/promises');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
@@ -127,11 +134,11 @@ describe(createServeSimArgs, () => {
'--webrtc-codec',
'vp8',
'--max-dimension',
'1280',
'960',
'--mjpeg-quality',
'0.55',
'--video-bitrate',
'3000000',
'6000000',
'--video-fps',
'60',
'--turn-url',
@@ -152,6 +159,63 @@ describe(createServeSimArgs, () => {
'https://expo.dev',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
]);
});
});
describe(createExpoDeviceHubArgs, () => {
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
expect(
createExpoDeviceHubArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
'--host',
'127.0.0.1',
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
'1280',
'--video-bitrate',
'6000000',
'--video-fps',
'60',
'--hide-sidebar',
'--hide-boot-device',
'--turn-url',
'turns:turn.example.test:443',
]);
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
@@ -181,7 +245,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
});
});
describe(waitForServeSimReadyAsync, () => {
describe(waitForWebPreviewReadyAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
jest.mocked(sleepAsync).mockReset();
@@ -196,8 +260,9 @@ describe(waitForServeSimReadyAsync, () => {
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
await waitForServeSimReadyAsync({
serveSim: { pid: undefined, getOutput: () => '' },
await waitForWebPreviewReadyAsync({
previewServer: { pid: undefined, getOutput: () => '' },
serverName: 'expo-device-hub',
port: 4321,
timeoutMs: 10_000,
});
@@ -216,14 +281,14 @@ describe(startNgrokTunnelAsync, () => {
it('uses a 128-bit capability hostname and exposes explicit cleanup', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://serve-sim.example.test',
url: () => 'https://web-preview.example.test',
close,
} as never);
const tunnel = await startNgrokTunnelAsync({
port: 4321,
subdomainPrefix: 'serve-sim',
baseDomain: 'tunnel.example.test',
subdomainPrefix: 'web-preview',
baseDomain: 'eas-simulator.ngrok.dev',
authtoken: 'token',
logger: createLoggerMock(),
});
@@ -232,24 +297,135 @@ describe(startNgrokTunnelAsync, () => {
expect.objectContaining({
addr: 4321,
authtoken: 'token',
domain: expect.stringMatching(/^serve-sim-[a-f0-9]{32}\.tunnel\.example\.test$/),
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
})
);
expect(tunnel.url).toBe('https://serve-sim.example.test');
expect(tunnel.url).toBe('https://web-preview.example.test');
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToServeSimArgs, () => {
describe(startDeviceWebPreviewWithTunnelAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
'--turn-url',
'turns:turn.example.test:443',
'--turn-username',
'turn-user',
'--turn-credential',
'turn-credential',
];
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
beforeEach(() => {
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
const spawnPromise = Object.assign(Promise.resolve(undefined), {
child: {
pid: undefined,
unref: jest.fn(),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
json: async () => ({
data: {
iceServers: [
{
urls: ['turns:turn.example.test:443'],
username: 'turn-user',
credential: 'turn-credential',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
return {
json: async () => ({ status: 'ready', device: 'device-id' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
});
});
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
const packageVersion = '4.5.6';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
@@ -274,7 +450,7 @@ describe(turnIceServersToServeSimArgs, () => {
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
@@ -292,19 +468,19 @@ describe(turnIceServersToServeSimArgs, () => {
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
describe(fetchWebPreviewTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
@@ -320,7 +496,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -344,11 +520,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
@@ -360,8 +536,13 @@ describe(fetchServeSimTurnArgsAsync, () => {
});
describe(waitForDeviceRunSessionStoppedAsync, () => {
const durationTimeout = {} as NodeJS.Timeout;
beforeEach(() => {
jest.mocked(Sentry).capture.mockReset();
jest.mocked(setTimeoutCallback).mockReset();
jest.mocked(setTimeoutCallback).mockReturnValue(durationTimeout);
jest.mocked(clearTimeoutCallback).mockReset();
jest.mocked(setTimeoutAsync).mockReset();
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
});
@@ -378,6 +559,57 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('returns normally when the maximum duration elapses', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const logger = createLoggerMock();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
maxDurationSeconds: 1,
});
expect(setTimeoutCallback).toHaveBeenCalledWith(expect.any(Function), 1_000);
const durationTimeoutCallback = jest.mocked(setTimeoutCallback).mock.calls[0][0];
durationTimeoutCallback();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id reached its maximum duration.'
);
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('clears the duration timeout when the session stops first', async () => {
await waitForDeviceRunSessionStoppedAsync({
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
});
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('does not poll when the build step is already aborted', async () => {
const ctx = createStatusCtxMock([]);
const abortController = new AbortController();
abortController.abort();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
signal: abortController.signal,
});
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('throws when the device run session errors', async () => {
@@ -388,8 +620,28 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
})
).rejects.toThrow('Device run session drs-id errored.');
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('clears the duration timeout when the build step is aborted', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const abortController = new AbortController();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
signal: abortController.signal,
});
abortController.abort();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('logs and retries transient polling errors', async () => {
@@ -0,0 +1,418 @@
import { type AppiumCommand } from './appiumCommands.generated';
export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
// Session lifecycle
createSession: 'Started the session',
deleteSession: 'Ended the session',
getSession: 'Read the session details',
getStatus: 'Checked the server status',
getAppiumSessions: 'Listed Appium sessions',
getAppiumSessionCapabilities: 'Read the session capabilities',
getTimeouts: 'Read timeouts',
timeouts: 'Set timeouts',
getSettings: 'Read Appium settings',
updateSettings: 'Updated Appium settings',
listCommands: 'Listed available commands',
listExtensions: 'Listed available extensions',
receiveAsyncResponse: 'Received an async response',
// Screen and media
getScreenshot: 'Took a screenshot',
getElementScreenshot: 'Took a screenshot of an element',
getPageSource: 'Read the screen contents',
printPage: 'Printed the page',
// Window and orientation
getWindowRect: 'Read the screen size',
setWindowRect: 'Resized the window',
maximizeWindow: 'Maximized the window',
minimizeWindow: 'Minimized the window',
fullScreenWindow: 'Made the window full screen',
getWindowHandle: 'Read the current window',
getWindowHandles: 'Listed open windows',
setWindow: 'Switched window',
closeWindow: 'Closed the window',
createNewWindow: 'Opened a new window',
setFrame: 'Switched to a frame',
switchToParentFrame: 'Switched to the parent frame',
getOrientation: 'Read the screen orientation',
setOrientation: 'Changed the screen orientation',
getRotation: 'Read the device rotation',
setRotation: 'Rotated the device',
// Device
getDeviceTime: 'Read the device time',
getGeoLocation: 'Read the device location',
setGeoLocation: 'Set the device location',
getNetworkConnection: 'Read the network connection',
setNetworkConnection: 'Changed the network connection',
setDevicePosture: 'Set the device posture',
clearDevicePosture: 'Cleared the device posture',
setPermissions: 'Set app permissions',
// Element discovery
findElement: 'Found an element',
findElements: 'Found elements',
findElementFromElement: 'Found a nested element',
findElementsFromElement: 'Found nested elements',
findElementFromShadowRoot: 'Found an element in a shadow root',
findElementsFromShadowRoot: 'Found elements in a shadow root',
elementShadowRoot: 'Read an element shadow root',
active: 'Read the focused element',
// Element interaction
click: 'Tapped an element',
clear: 'Cleared an element',
setValue: 'Typed into an element',
getText: 'Read element text',
getName: 'Read an element tag',
getAttribute: 'Read an element attribute',
getProperty: 'Read an element property',
getCssProperty: 'Read an element style',
getComputedLabel: 'Read an element label',
getComputedRole: 'Read an element role',
getElementRect: 'Read an element position and size',
elementDisplayed: 'Checked if an element was visible',
elementEnabled: 'Checked if an element was enabled',
elementSelected: 'Checked if an element was selected',
// Gestures
performActions: 'Performed a gesture',
releaseActions: 'Finished a gesture',
// App management
installApp: 'Installed an app',
removeApp: 'Removed an app',
isAppInstalled: 'Checked if an app was installed',
activateApp: 'Activated an app',
terminateApp: 'Terminated an app',
queryAppState: 'Read the app state',
background: 'Sent the app to the background',
// Contexts
getContexts: 'Listed available contexts',
getCurrentContext: 'Read the current context',
setContext: 'Switched context',
// Web navigation
setUrl: 'Opened a URL',
getUrl: 'Read the current URL',
back: 'Navigated back',
forward: 'Navigated forward',
refresh: 'Refreshed the page',
title: 'Read the page title',
// Cookies
getCookie: 'Read a cookie',
getCookies: 'Read all cookies',
setCookie: 'Set a cookie',
deleteCookie: 'Deleted a cookie',
deleteCookies: 'Deleted all cookies',
// Alerts
getAlertText: 'Read an alert',
setAlertText: 'Typed into an alert',
postAcceptAlert: 'Accepted an alert',
postDismissAlert: 'Dismissed an alert',
// Keyboard and input methods
hideKeyboard: 'Hid the keyboard',
isKeyboardShown: 'Checked if the keyboard was shown',
activateIMEEngine: 'Activated an input method',
deactivateIMEEngine: 'Deactivated the input method',
getActiveIMEEngine: 'Read the active input method',
availableIMEEngines: 'Listed input methods',
isIMEActivated: 'Checked if an input method was active',
// Clipboard (driver-level, not declared by base-driver)
getClipboard: 'Read the clipboard',
setClipboard: 'Wrote to the clipboard',
// Files
pushFile: 'Pushed a file to the device',
pullFile: 'Pulled a file from the device',
pullFolder: 'Pulled a folder from the device',
// Logs
getLog: 'Read device logs',
getLogTypes: 'Listed log types',
getLogEvents: 'Read logged events',
logCustomEvent: 'Logged a custom event',
generateTestReport: 'Generated a test report',
// Scripts
execute: 'Ran a script command',
executeAsync: 'Ran an async script command',
executeCdp: 'Ran a Chrome DevTools command',
// Web authentication (virtual authenticators)
addVirtualAuthenticator: 'Added a virtual authenticator',
removeVirtualAuthenticator: 'Removed a virtual authenticator',
addAuthCredential: 'Added an auth credential',
getAuthCredential: 'Read auth credentials',
removeAuthCredential: 'Removed an auth credential',
removeAllAuthCredentials: 'Removed all auth credentials',
setUserAuthVerified: 'Set the user verification state',
// Federated sign-in (FedCM)
fedCMGetAccounts: 'Listed federated sign-in accounts',
fedCMSelectAccount: 'Selected a federated sign-in account',
fedCMGetDialogType: 'Read the federated sign-in dialog type',
fedCMGetTitle: 'Read the federated sign-in dialog title',
fedCMClickDialogButton: 'Clicked a federated sign-in dialog button',
fedCMCancelDialog: 'Canceled the federated sign-in dialog',
fedCMResetCooldown: 'Reset the federated sign-in cooldown',
fedCMSetDelayEnabled: 'Toggled the federated sign-in delay',
// Virtual sensors and pressure sources
createVirtualSensor: 'Created a virtual sensor',
updateVirtualSensorReading: 'Updated a virtual sensor reading',
getVirtualSensorInfo: 'Read virtual sensor info',
deleteVirtualSensor: 'Removed a virtual sensor',
createVirtualPressureSource: 'Created a virtual pressure source',
updateVirtualPressureSource: 'Updated a virtual pressure source',
deleteVirtualPressureSource: 'Removed a virtual pressure source',
// Privacy / experimental web platform
getGlobalPrivacyControl: 'Read the Global Privacy Control setting',
setGlobalPrivacyControl: 'Set the Global Privacy Control setting',
setStorageAccess: 'Set storage access',
setRPHRegistrationMode: 'Set the protocol handler registration mode',
setSPCTransactionMode: 'Set the payment transaction mode',
// Driver routes and compatibility commands
asyncScriptTimeout: 'Set the async script timeout',
closeApp: 'Closed the app',
disableConditionInducer: 'Disabled the condition inducer',
enableConditionInducer: 'Enabled the condition inducer',
fingerprint: 'Simulated a fingerprint',
getCurrentActivity: 'Read the current activity',
getCurrentPackage: 'Read the current package',
getDisplayDensity: 'Read the display density',
getLocation: 'Read an element location',
getLocationInView: 'Read an element location in view',
getPerformanceData: 'Read performance data',
getPerformanceDataTypes: 'Listed performance data types',
getScreenInfo: 'Read screen information',
getSize: 'Read an element size',
getStrings: 'Read app strings',
getSystemBars: 'Read system bar information',
getViewportRect: 'Read the viewport size',
getViewportScreenshot: 'Took a viewport screenshot',
getWindowSize: 'Read the window size',
gsmCall: 'Simulated a phone call',
gsmSignal: 'Changed the cellular signal',
gsmVoice: 'Changed the cellular voice state',
implicitWait: 'Set the element lookup timeout',
isLocationServicesEnabled: 'Checked if location services were enabled',
isLocked: 'Checked if the device was locked',
keyevent: 'Sent a key event',
keys: 'Sent keyboard input',
launchApp: 'Launched the app',
listConditionInducers: 'Listed condition inducers',
lock: 'Locked the device',
longPressKeyCode: 'Long-pressed a key',
networkSpeed: 'Changed the network speed',
openNotifications: 'Opened notifications',
powerAC: 'Changed the charging state',
powerCapacity: 'Changed the battery level',
pressKeyCode: 'Pressed a key',
replaceValue: 'Replaced an element value',
reset: 'Reset the app',
sendSMS: 'Simulated a text message',
sensorSet: 'Changed a sensor value',
setStylusHandwriting: 'Changed stylus handwriting mode',
setValueImmediate: 'Typed into an element immediately',
startActivity: 'Started an activity',
startAudioRecording: 'Started audio recording',
startRecordingScreen: 'Started screen recording',
stopAudioRecording: 'Stopped audio recording',
stopRecordingScreen: 'Stopped screen recording',
submit: 'Submitted an element',
toggleData: 'Toggled mobile data',
toggleEnrollTouchId: 'Changed Touch ID enrollment',
toggleFlightMode: 'Toggled airplane mode',
toggleLocationServices: 'Toggled location services',
toggleWiFi: 'Toggled Wi-Fi',
touchId: 'Simulated Touch ID',
unlock: 'Unlocked the device',
// Driver mobile commands
mobileAcceptAlert: 'Accepted an alert',
mobileActivateApp: 'Activated an app',
mobileBackgroundApp: 'Sent the app to the background',
mobileBluetooth: 'Changed Bluetooth state',
mobileBroadcast: 'Sent an Android broadcast',
mobileCalibrateWebToRealCoordinatesTranslation: 'Calibrated web coordinates',
mobileChangePermissions: 'Changed app permissions',
mobileClearApp: 'Cleared app data',
mobileClearKeychains: 'Cleared keychains',
mobileClickGesture: 'Performed a click gesture',
mobileConfigureLocalization: 'Changed device localization',
mobileDeepLink: 'Opened a deep link',
mobileDeleteFile: 'Deleted a device file',
mobileDeleteFolder: 'Deleted a device folder',
mobileDeviceidle: 'Changed device idle state',
mobileDisableVoiceOver: 'Disabled VoiceOver',
mobileDismissAlert: 'Dismissed an alert',
mobileDoubleClickGesture: 'Performed a double-click gesture',
mobileDoubleTap: 'Double-tapped the screen',
mobileDragFromToForDuration: 'Dragged between screen points',
mobileDragFromToWithVelocity: 'Dragged with a set velocity',
mobileDragGesture: 'Performed a drag gesture',
mobileEnableVoiceOver: 'Enabled VoiceOver',
mobileEnrollBiometric: 'Changed biometric enrollment',
mobileExecEmuConsoleCommand: 'Ran an emulator console command',
mobileExpectNotification: 'Waited for a notification',
mobileFingerprint: 'Simulated a fingerprint',
mobileFlingGesture: 'Performed a fling gesture',
mobileForcePress: 'Force-pressed the screen',
mobileGetActionHistory: 'Read scheduled action history',
mobileGetActiveAppInfo: 'Read active app information',
mobileGetAppearance: 'Read the device appearance',
mobileGetBatteryInfo: 'Read battery information',
mobileGetChromeCapabilities: 'Read Chrome capabilities',
mobileGetConnectivity: 'Read device connectivity',
mobileGetContentSize: 'Read scrollable content size',
mobileGetContexts: 'Listed available contexts',
mobileGetDeviceInfo: 'Read device information',
mobileGetDeviceTime: 'Read the device time',
mobileGetDeclaredOrientation: 'Read the declared device orientation',
mobileGetGeolocation: 'Read the device location',
mobileGetIncreaseContrast: 'Read the Increase Contrast setting',
mobileGetNotifications: 'Read notifications',
mobileGetPasteboard: 'Read the pasteboard',
mobileGetPerformanceData: 'Read app performance data',
mobileGetPermission: 'Read an app permission',
mobileGetPermissions: 'Read app permissions',
mobileGetSimulatedLocation: 'Read the simulated location',
mobileGetSource: 'Read the screen contents',
mobileGetUiMode: 'Read the Android UI mode',
mobileGetXctestScreenRecordingInfo: 'Read XCTest screen recording information',
mobileGsmCall: 'Simulated a phone call',
mobileGsmSignal: 'Changed the cellular signal',
mobileGsmVoice: 'Changed the cellular voice state',
mobileHandleAlert: 'Handled an alert',
mobileHideKeyboard: 'Hid the keyboard',
mobileInjectEmulatorCameraImage: 'Injected an emulator camera image',
mobileInstallApp: 'Installed an app',
mobileInstallCertificate: 'Installed a certificate',
mobileInstallMultipleApks: 'Installed multiple APKs',
mobileInstallXCTestBundle: 'Installed an XCTest bundle',
mobileIsAppInstalled: 'Checked if an app was installed',
mobileIsBiometricEnrolled: 'Checked biometric enrollment',
mobileIsMediaProjectionRecordingRunning: 'Checked media projection recording',
mobileIsVoiceOverEnabled: 'Checked if VoiceOver was enabled',
mobileKeys: 'Sent keyboard input',
mobileKillApp: 'Stopped an app',
mobileLaunchApp: 'Launched an app',
mobileListApps: 'Listed installed apps',
mobileListCertificates: 'Listed certificates',
mobileListDisplays: 'Listed displays',
mobileListSms: 'Listed text messages',
mobileListWindows: 'Listed app windows',
mobileListXCTestBundles: 'Listed XCTest bundles',
mobileLongClickGesture: 'Performed a long-click gesture',
mobileNetworkSpeed: 'Changed the network speed',
mobileNfc: 'Changed NFC state',
mobilePerformAccessibilityAudit: 'Ran an accessibility audit',
mobilePerformEditorAction: 'Performed a keyboard editor action',
mobilePerformHandGesture: 'Performed a hand gesture',
mobilePerformIndigoHidEvent: 'Performed an Indigo HID event',
mobilePerformIoHidEvent: 'Performed an IOHID event',
mobilePerformStatusBarCommand: 'Performed a status bar command',
mobilePinch: 'Pinched the screen',
mobilePinchCloseGesture: 'Performed a pinch-close gesture',
mobilePinchOpenGesture: 'Performed a pinch-open gesture',
mobilePowerAc: 'Changed the charging state',
mobilePowerCapacity: 'Changed the battery level',
mobilePressButton: 'Pressed a device button',
mobilePressKey: 'Pressed a key',
mobilePullFile: 'Pulled a file from the device',
mobilePullFolder: 'Pulled a folder from the device',
mobilePushFile: 'Pushed a file to the device',
mobilePushNotification: 'Sent a push notification',
mobileQueryAppState: 'Read the app state',
mobileRefreshGpsCache: 'Refreshed the GPS cache',
mobileRemoveApp: 'Removed an app',
mobileRemoveCertificate: 'Removed a certificate',
mobileReplaceElementValue: 'Replaced an element value',
mobileResetAccessibilityCache: 'Reset the accessibility cache',
mobileResetGeolocation: 'Reset the device location',
mobileResetLocationService: 'Reset location services',
mobileResetPermission: 'Reset an app permission',
mobileResetSimulatedLocation: 'Reset the simulated location',
mobileRotateDigitalCrown: 'Rotated the Digital Crown',
mobileRotateElement: 'Rotated an element',
mobileRunXCTest: 'Ran an XCTest bundle',
mobileScheduleAction: 'Scheduled a device action',
mobileScreenshots: 'Took display screenshots',
mobileScroll: 'Scrolled the screen',
mobileScrollBackTo: 'Scrolled back to an element',
mobileScrollGesture: 'Performed a scroll gesture',
mobileScrollToElement: 'Scrolled to an element',
mobileSelectPickerWheelValue: 'Changed a picker wheel value',
mobileSendBiometricMatch: 'Simulated biometric authentication',
mobileSendMemoryWarning: 'Sent a memory warning',
mobileSendSms: 'Simulated a text message',
mobileSendTrimMemory: 'Sent an Android memory trim event',
mobileSetAppearance: 'Changed the device appearance',
mobileSetConnectivity: 'Changed device connectivity',
mobileSetContentSize: 'Changed the content size',
mobileSetGeolocation: 'Set the device location',
mobileSetIncreaseContrast: 'Changed the Increase Contrast setting',
mobileSetPasteboard: 'Wrote to the pasteboard',
mobileSetPermissions: 'Set app permissions',
mobileSetSimulatedLocation: 'Set a simulated location',
mobileSetUiMode: 'Changed the Android UI mode',
mobileShake: 'Shook the device',
mobileShell: 'Ran an Android shell command',
mobileSimctl: 'Ran a simctl command',
mobileSiriCommand: 'Sent a Siri command',
mobileStartActivity: 'Started an activity',
mobileStartLogsBroadcast: 'Started log streaming',
mobileStartMediaProjectionRecording: 'Started media projection recording',
mobileStartNetworkMonitor: 'Started network monitoring',
mobileStartPerfRecord: 'Started performance recording',
mobileStartScreenRecording: 'Started screen recording',
mobileStartScreenStreaming: 'Started screen streaming',
mobileStartService: 'Started a service',
mobileStartSystemMonitor: 'Started system monitoring',
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
mobileStopLogsBroadcast: 'Stopped log streaming',
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
mobileStopNetworkMonitor: 'Stopped network monitoring',
mobileStopPerfRecord: 'Stopped performance recording',
mobileStopScreenRecording: 'Stopped screen recording',
mobileStopScreenStreaming: 'Stopped screen streaming',
mobileStopService: 'Stopped a service',
mobileStopSystemMonitor: 'Stopped system monitoring',
mobileStopXctestScreenRecording: 'Stopped XCTest screen recording',
mobileSwipe: 'Swiped the screen',
mobileSwipeGesture: 'Performed a swipe gesture',
mobileTap: 'Tapped the screen',
mobileTapWithNumberOfTaps: 'Tapped the screen multiple times',
mobileTerminateApp: 'Terminated an app',
mobileTouchAndHold: 'Touched and held the screen',
mobileTwoFingerTap: 'Tapped with two fingers',
mobileType: 'Typed text',
mobileUnlock: 'Unlocked the device',
mobileUnscheduleAction: 'Canceled a scheduled action',
mobileUpdateSafariPreferences: 'Updated Safari preferences',
mobileViewPortRect: 'Read the viewport size',
mobileViewportScreenshot: 'Took a viewport screenshot',
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
};
/**
* Translate a raw Appium command name into a short, human-readable summary.
*
* If a command has no curated summary, the raw command name is returned
* unchanged — we intentionally do not guess a phrasing.
*/
export function humanizeAppiumCommand(command: string): string {
return Object.hasOwn(APPIUM_COMMAND_SUMMARIES, command)
? (APPIUM_COMMAND_SUMMARIES as Record<string, string>)[command]
: command;
}
@@ -0,0 +1,401 @@
// 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).
export type BaseDriverAppiumCommand =
| 'activateApp'
| 'activateIMEEngine'
| 'active'
| 'addAuthCredential'
| 'addVirtualAuthenticator'
| 'availableIMEEngines'
| 'back'
| 'clear'
| 'clearDevicePosture'
| 'click'
| 'closeWindow'
| 'createNewWindow'
| 'createSession'
| 'createVirtualPressureSource'
| 'createVirtualSensor'
| 'deactivateIMEEngine'
| 'deleteCookie'
| 'deleteCookies'
| 'deleteSession'
| 'deleteVirtualPressureSource'
| 'deleteVirtualSensor'
| 'elementDisplayed'
| 'elementEnabled'
| 'elementSelected'
| 'elementShadowRoot'
| 'execute'
| 'executeAsync'
| 'executeCdp'
| 'fedCMCancelDialog'
| 'fedCMClickDialogButton'
| 'fedCMGetAccounts'
| 'fedCMGetDialogType'
| 'fedCMGetTitle'
| 'fedCMResetCooldown'
| 'fedCMSelectAccount'
| 'fedCMSetDelayEnabled'
| 'findElement'
| 'findElementFromElement'
| 'findElementFromShadowRoot'
| 'findElements'
| 'findElementsFromElement'
| 'findElementsFromShadowRoot'
| 'forward'
| 'fullScreenWindow'
| 'generateTestReport'
| 'getActiveIMEEngine'
| 'getAlertText'
| 'getAppiumSessionCapabilities'
| 'getAppiumSessions'
| 'getAttribute'
| 'getAuthCredential'
| 'getComputedLabel'
| 'getComputedRole'
| 'getContexts'
| 'getCookie'
| 'getCookies'
| 'getCssProperty'
| 'getCurrentContext'
| 'getDeviceTime'
| 'getElementRect'
| 'getElementScreenshot'
| 'getGeoLocation'
| 'getGlobalPrivacyControl'
| 'getLog'
| 'getLogEvents'
| 'getLogTypes'
| 'getName'
| 'getNetworkConnection'
| 'getOrientation'
| 'getPageSource'
| 'getProperty'
| 'getRotation'
| 'getScreenshot'
| 'getSession'
| 'getSettings'
| 'getStatus'
| 'getText'
| 'getTimeouts'
| 'getUrl'
| 'getVirtualSensorInfo'
| 'getWindowHandle'
| 'getWindowHandles'
| 'getWindowRect'
| 'hideKeyboard'
| 'installApp'
| 'isAppInstalled'
| 'isIMEActivated'
| 'isKeyboardShown'
| 'listCommands'
| 'listExtensions'
| 'logCustomEvent'
| 'maximizeWindow'
| 'minimizeWindow'
| 'performActions'
| 'postAcceptAlert'
| 'postDismissAlert'
| 'printPage'
| 'pullFile'
| 'pullFolder'
| 'pushFile'
| 'queryAppState'
| 'receiveAsyncResponse'
| 'refresh'
| 'releaseActions'
| 'removeAllAuthCredentials'
| 'removeApp'
| 'removeAuthCredential'
| 'removeVirtualAuthenticator'
| 'setAlertText'
| 'setContext'
| 'setCookie'
| 'setDevicePosture'
| 'setFrame'
| 'setGeoLocation'
| 'setGlobalPrivacyControl'
| 'setNetworkConnection'
| 'setOrientation'
| 'setPermissions'
| 'setRPHRegistrationMode'
| 'setRotation'
| 'setSPCTransactionMode'
| 'setStorageAccess'
| 'setUrl'
| 'setUserAuthVerified'
| 'setValue'
| 'setWindow'
| 'setWindowRect'
| 'switchToParentFrame'
| 'terminateApp'
| 'timeouts'
| 'title'
| 'updateSettings'
| 'updateVirtualPressureSource'
| 'updateVirtualSensorReading';
export type XCUITestAppiumCommand =
| 'asyncScriptTimeout'
| 'background'
| 'closeApp'
| 'disableConditionInducer'
| 'enableConditionInducer'
| 'getClipboard'
| 'getLocation'
| 'getLocationInView'
| 'getScreenInfo'
| 'getSize'
| 'getStrings'
| 'getViewportRect'
| 'getViewportScreenshot'
| 'getWindowSize'
| 'implicitWait'
| 'isLocked'
| 'keys'
| 'launchApp'
| 'listConditionInducers'
| 'lock'
| 'mobileActivateApp'
| 'mobileCalibrateWebToRealCoordinatesTranslation'
| 'mobileClearApp'
| 'mobileClearKeychains'
| 'mobileConfigureLocalization'
| 'mobileDeepLink'
| 'mobileDeleteFile'
| 'mobileDeleteFolder'
| 'mobileDisableVoiceOver'
| 'mobileDoubleTap'
| 'mobileDragFromToForDuration'
| 'mobileDragFromToWithVelocity'
| 'mobileEnableVoiceOver'
| 'mobileEnrollBiometric'
| 'mobileExpectNotification'
| 'mobileForcePress'
| 'mobileGetActiveAppInfo'
| 'mobileGetAppearance'
| 'mobileGetBatteryInfo'
| 'mobileGetContentSize'
| 'mobileGetContexts'
| 'mobileGetDeviceInfo'
| 'mobileGetDeviceTime'
| 'mobileGetIncreaseContrast'
| 'mobileGetPasteboard'
| 'mobileGetPermission'
| 'mobileGetSimulatedLocation'
| 'mobileGetSource'
| 'mobileGetXctestScreenRecordingInfo'
| 'mobileHandleAlert'
| 'mobileHideKeyboard'
| 'mobileInstallApp'
| 'mobileInstallCertificate'
| 'mobileInstallXCTestBundle'
| 'mobileIsAppInstalled'
| 'mobileIsBiometricEnrolled'
| 'mobileIsVoiceOverEnabled'
| 'mobileKeys'
| 'mobileKillApp'
| 'mobileLaunchApp'
| 'mobileListApps'
| 'mobileListCertificates'
| 'mobileListXCTestBundles'
| 'mobilePerformAccessibilityAudit'
| 'mobilePerformHandGesture'
| 'mobilePerformIndigoHidEvent'
| 'mobilePerformIoHidEvent'
| 'mobilePinch'
| 'mobilePressButton'
| 'mobilePullFile'
| 'mobilePullFolder'
| 'mobilePushFile'
| 'mobilePushNotification'
| 'mobileQueryAppState'
| 'mobileRemoveApp'
| 'mobileRemoveCertificate'
| 'mobileResetLocationService'
| 'mobileResetPermission'
| 'mobileResetSimulatedLocation'
| 'mobileRotateDigitalCrown'
| 'mobileRotateElement'
| 'mobileRunXCTest'
| 'mobileScroll'
| 'mobileScrollToElement'
| 'mobileSelectPickerWheelValue'
| 'mobileSendBiometricMatch'
| 'mobileSendMemoryWarning'
| 'mobileSetAppearance'
| 'mobileSetContentSize'
| 'mobileSetIncreaseContrast'
| 'mobileSetPasteboard'
| 'mobileSetPermissions'
| 'mobileSetSimulatedLocation'
| 'mobileShake'
| 'mobileSimctl'
| 'mobileSiriCommand'
| 'mobileStartLogsBroadcast'
| 'mobileStartNetworkMonitor'
| 'mobileStartPerfRecord'
| 'mobileStartScreenRecording'
| 'mobileStartSystemMonitor'
| 'mobileStartXctestScreenRecording'
| 'mobileStopLogsBroadcast'
| 'mobileStopNetworkMonitor'
| 'mobileStopPerfRecord'
| 'mobileStopScreenRecording'
| 'mobileStopSystemMonitor'
| 'mobileStopXctestScreenRecording'
| 'mobileSwipe'
| 'mobileTap'
| 'mobileTapWithNumberOfTaps'
| 'mobileTerminateApp'
| 'mobileTouchAndHold'
| 'mobileTwoFingerTap'
| 'mobileUpdateSafariPreferences'
| 'mobileVoiceOverCurrentSpeech'
| 'mobileVoiceOverMove'
| 'reset'
| 'setClipboard'
| 'setValueImmediate'
| 'startAudioRecording'
| 'startRecordingScreen'
| 'stopAudioRecording'
| 'stopRecordingScreen'
| 'submit'
| 'toggleEnrollTouchId'
| 'touchId'
| 'unlock';
export type UiAutomator2AppiumCommand =
| 'background'
| 'fingerprint'
| 'getClipboard'
| 'getCurrentActivity'
| 'getCurrentPackage'
| 'getDisplayDensity'
| 'getLocation'
| 'getLocationInView'
| 'getPerformanceData'
| 'getPerformanceDataTypes'
| 'getSize'
| 'getStrings'
| 'getSystemBars'
| 'getWindowSize'
| 'gsmCall'
| 'gsmSignal'
| 'gsmVoice'
| 'implicitWait'
| 'isLocationServicesEnabled'
| 'isLocked'
| 'keyevent'
| 'keys'
| 'lock'
| 'longPressKeyCode'
| 'mobileAcceptAlert'
| 'mobileBackgroundApp'
| 'mobileBluetooth'
| 'mobileBroadcast'
| 'mobileChangePermissions'
| 'mobileClearApp'
| 'mobileClickGesture'
| 'mobileDeepLink'
| 'mobileDeleteFile'
| 'mobileDeviceidle'
| 'mobileDismissAlert'
| 'mobileDoubleClickGesture'
| 'mobileDragGesture'
| 'mobileExecEmuConsoleCommand'
| 'mobileFingerprint'
| 'mobileFlingGesture'
| 'mobileGetActionHistory'
| 'mobileGetBatteryInfo'
| 'mobileGetChromeCapabilities'
| 'mobileGetConnectivity'
| 'mobileGetContexts'
| 'mobileGetDeclaredOrientation'
| 'mobileGetDeviceInfo'
| 'mobileGetDeviceTime'
| 'mobileGetGeolocation'
| 'mobileGetNotifications'
| 'mobileGetPerformanceData'
| 'mobileGetPermissions'
| 'mobileGetUiMode'
| 'mobileGsmCall'
| 'mobileGsmSignal'
| 'mobileGsmVoice'
| 'mobileInjectEmulatorCameraImage'
| 'mobileInstallApp'
| 'mobileInstallMultipleApks'
| 'mobileIsAppInstalled'
| 'mobileIsMediaProjectionRecordingRunning'
| 'mobileListApps'
| 'mobileListDisplays'
| 'mobileListSms'
| 'mobileListWindows'
| 'mobileLongClickGesture'
| 'mobileNetworkSpeed'
| 'mobileNfc'
| 'mobilePerformEditorAction'
| 'mobilePerformStatusBarCommand'
| 'mobilePinchCloseGesture'
| 'mobilePinchOpenGesture'
| 'mobilePowerAc'
| 'mobilePowerCapacity'
| 'mobilePressKey'
| 'mobileRefreshGpsCache'
| 'mobileRemoveApp'
| 'mobileReplaceElementValue'
| 'mobileResetAccessibilityCache'
| 'mobileResetGeolocation'
| 'mobileScheduleAction'
| 'mobileScreenshots'
| 'mobileScroll'
| 'mobileScrollBackTo'
| 'mobileScrollGesture'
| 'mobileSendSms'
| 'mobileSendTrimMemory'
| 'mobileSetConnectivity'
| 'mobileSetGeolocation'
| 'mobileSetUiMode'
| 'mobileShell'
| 'mobileStartActivity'
| 'mobileStartLogsBroadcast'
| 'mobileStartMediaProjectionRecording'
| 'mobileStartScreenStreaming'
| 'mobileStartService'
| 'mobileStopLogsBroadcast'
| 'mobileStopMediaProjectionRecording'
| 'mobileStopScreenStreaming'
| 'mobileStopService'
| 'mobileSwipeGesture'
| 'mobileTerminateApp'
| 'mobileType'
| 'mobileUnlock'
| 'mobileUnscheduleAction'
| 'mobileViewPortRect'
| 'mobileViewportScreenshot'
| 'networkSpeed'
| 'openNotifications'
| 'powerAC'
| 'powerCapacity'
| 'pressKeyCode'
| 'replaceValue'
| 'sendSMS'
| 'sensorSet'
| 'setClipboard'
| 'setStylusHandwriting'
| 'setValueImmediate'
| 'startActivity'
| 'startRecordingScreen'
| 'stopRecordingScreen'
| 'toggleData'
| 'toggleFlightMode'
| 'toggleLocationServices'
| 'toggleWiFi'
| 'unlock';
export type AppiumCommand =
| BaseDriverAppiumCommand
| XCUITestAppiumCommand
| UiAutomator2AppiumCommand;
@@ -0,0 +1,217 @@
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { turtleFetch } from '../../utils/turtleFetch';
import { humanizeAppiumCommand } from './appiumCommandSummary';
import {
type DeviceRunSessionEventCollection,
type DeviceRunSessionEventSource,
startDeviceRunSessionEventCollectionAsync,
} from './deviceRunSessionEvents';
const APPIUM_REQUEST_TIMEOUT_MS = 10_000;
const POLL_INTERVAL_MS = 1_000;
const GET_LOG_EVENTS_COMMAND = 'getLogEvents';
const AppiumSessionsResponseSchema = z.object({
value: z.array(z.object({ id: z.string() }).passthrough()),
});
const AppiumCommandSchema = z.object({
appiumSessionId: z.string(),
cmd: z.string(),
startTime: z.number(),
endTime: z.number(),
});
const AppiumEventsResponseSchema = z.object({
value: z.object({
commands: z.array(AppiumCommandSchema.omit({ appiumSessionId: true })),
}),
});
type AppiumCommand = z.infer<typeof AppiumCommandSchema>;
export async function startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl,
logger,
pollIntervalMs = POLL_INTERVAL_MS,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
appiumUrl: string;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<DeviceRunSessionEventCollection> {
const eventDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-events-'));
const eventFile = path.join(eventDirectory, 'commands.ndjson');
await fs.promises.writeFile(eventFile, '');
let eventCollection: DeviceRunSessionEventCollection;
try {
eventCollection = await startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
logger,
pollIntervalMs,
source: createAppiumEventSource(eventFile),
});
} catch (error) {
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
throw error;
}
const observedCommandKeys = new Set<string>();
const controller = new AbortController();
let didReportCollectionFailure = false;
const collectSafelyAsync = async (): Promise<void> => {
try {
await collectAppiumCommandsAsync({
appiumUrl,
eventFile,
observedCommandKeys,
logger,
});
didReportCollectionFailure = false;
} catch (error) {
if (!didReportCollectionFailure) {
didReportCollectionFailure = true;
logger.warn({ err: error }, 'Could not collect Appium events.');
}
}
};
const pollingPromise = (async () => {
while (!controller.signal.aborted) {
await collectSafelyAsync();
try {
await setTimeoutAsync(pollIntervalMs, undefined, { signal: controller.signal });
} catch (error) {
if (!controller.signal.aborted) {
throw error;
}
}
}
})().catch(error => {
logger.warn({ err: error }, 'Appium event collection poller failed.');
});
return {
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
stopAsync: async () => {
controller.abort();
await pollingPromise;
await collectSafelyAsync();
try {
await eventCollection.stopAsync();
} finally {
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
}
},
};
}
function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource {
return {
producer: 'appium',
findEventFilesAsync: async () => [eventFile],
sourceKeyForFile: file => path.basename(file, path.extname(file)),
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
if (!line.trim()) {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return { failure: 'invalid-json' };
}
const result = AppiumCommandSchema.safeParse(parsed);
if (!result.success) {
return { failure: 'invalid-event' };
}
const command = result.data;
const operationId = `${sourceKey}:${sequenceNumber}`;
return {
event: {
v: 1,
eventId: `appium:${deviceRunSessionId}:${operationId}`,
ts: new Date(command.endTime).toISOString(),
producer: 'appium',
type: 'operation.completed',
operationId,
durationMs: Math.max(0, command.endTime - command.startTime),
summary: humanizeAppiumCommand(command.cmd),
data: {
command: command.cmd,
appiumSessionId: command.appiumSessionId,
},
},
};
},
};
}
async function collectAppiumCommandsAsync({
appiumUrl,
eventFile,
observedCommandKeys,
logger,
}: {
appiumUrl: string;
eventFile: string;
observedCommandKeys: Set<string>;
logger: bunyan;
}): Promise<void> {
const response = await turtleFetch(new URL('appium/sessions', appiumUrl).toString(), 'GET', {
timeout: APPIUM_REQUEST_TIMEOUT_MS,
retries: 0,
logger,
});
const sessions = AppiumSessionsResponseSchema.parse(await response.json()).value;
const newCommands: { key: string; command: AppiumCommand }[] = [];
const pendingCommandKeys = new Set<string>();
for (const { id: appiumSessionId } of sessions) {
const eventsResponse = await turtleFetch(
new URL(`session/${encodeURIComponent(appiumSessionId)}/appium/events`, appiumUrl).toString(),
'POST',
{
json: {},
timeout: APPIUM_REQUEST_TIMEOUT_MS,
retries: 0,
logger,
}
);
const commands = AppiumEventsResponseSchema.parse(await eventsResponse.json()).value.commands;
for (const command of commands) {
const key = [appiumSessionId, command.cmd, command.startTime, command.endTime].join(':');
if (
command.cmd === GET_LOG_EVENTS_COMMAND ||
observedCommandKeys.has(key) ||
pendingCommandKeys.has(key)
) {
continue;
}
pendingCommandKeys.add(key);
newCommands.push({ key, command: { appiumSessionId, ...command } });
}
}
if (newCommands.length === 0) {
return;
}
await fs.promises.appendFile(
eventFile,
`${newCommands.map(({ command }) => JSON.stringify(command)).join('\n')}\n`
);
for (const { key } of newCommands) {
observedCommandKeys.add(key);
}
}
@@ -18,11 +18,11 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
// set, so a `screen-recording` kind here does not add a player to the session page.
// Fallback kinds for Argent versions that do not include a semantic artifact kind. Only media types
// the EAS dashboard renders specially can be inferred safely. Note that these kinds only affect
// grouping and labelling. The dashboard gates its inline video player on the
// `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not set, so a
// `screen-recording` kind here does not add a player to the session page.
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
['image/png', 'screenshot'],
['image/jpeg', 'screenshot'],
@@ -32,6 +32,9 @@ const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
const ArgentArtifactSchema = z.object({
id: z.string(),
// Optional for compatibility with older Argent tool servers. Keep this open to new kinds so a
// newer Argent version can add a category without requiring an EAS worker release first.
kind: z.string().optional(),
filename: z.string(),
mimeType: z.string(),
isDirectory: z.boolean().optional(),
@@ -43,8 +46,12 @@ const ArgentArtifactsListResponseSchema = z.object({
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
if (artifact.kind) {
return artifact.kind;
}
// Directories are repackaged as a tarball before upload, so the reported media type describes the
// contents rather than the file we actually store.
// contents rather than the file we actually store. A semantic kind above remains valid because it
// describes what the artifact represents, not its transport format.
if (artifact.isDirectory) {
return undefined;
}
@@ -272,6 +272,10 @@ export type AscApiClientPostApi = {
};
};
// https://developer.apple.com/documentation/appstoreconnectapi/platform
export type AscPlatform =
AscApiClientPostApi['/v1/buildUploads']['request']['data']['attributes']['platform'];
export type AscApiClientPatchApi = {
[Path in keyof typeof PatchApi]: {
request: z.input<(typeof PatchApi)[Path]['request']>;
@@ -5,9 +5,44 @@ import {
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
AscPlatform,
} from './AscApiClient';
export namespace AscApiUtils {
/**
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
* Connect platform used for a build upload. Unknown or missing values fall
* back to `IOS` to preserve the previous default.
*/
export function ascPlatformFromDtPlatformName(dtPlatformName: string | null): AscPlatform {
switch (dtPlatformName) {
case 'appletvos':
return 'TV_OS';
case 'macosx':
return 'MAC_OS';
case 'xros':
return 'VISION_OS';
case 'iphoneos':
default:
return 'IOS';
}
}
/** The App Store Connect TestFlight URL path segment for a platform. */
export function testFlightPlatformPathSegment(platform: AscPlatform): string {
switch (platform) {
case 'TV_OS':
return 'tvos';
case 'MAC_OS':
return 'macos';
case 'VISION_OS':
return 'visionos';
case 'IOS':
default:
return 'ios';
}
}
export async function getAppInfoAsync({
client,
appleAppIdentifier,
@@ -61,18 +96,20 @@ export namespace AscApiUtils {
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
}: {
client: Pick<AscApiClient, 'postAsync'>;
appleAppIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
platform: AscPlatform;
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
try {
return await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
platform,
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
@@ -148,6 +148,7 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toEqual(
expect.objectContaining({
@@ -176,10 +177,40 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).resolves.toEqual(response);
});
it('sends the requested platform in the build upload attributes', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'TV_OS',
});
expect(client.postAsync).toHaveBeenCalledWith(
'/v1/buildUploads',
expect.objectContaining({
data: expect.objectContaining({
attributes: expect.objectContaining({ platform: 'TV_OS' }),
}),
})
);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
@@ -208,8 +239,32 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toBe(mixedError);
});
});
describe('ascPlatformFromDtPlatformName', () => {
it('maps known DTPlatformName values to App Store Connect platforms', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName('iphoneos')).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('appletvos')).toBe('TV_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('macosx')).toBe('MAC_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('xros')).toBe('VISION_OS');
});
it('falls back to IOS for unknown or missing values', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName(null)).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('watchos')).toBe('IOS');
});
});
describe('testFlightPlatformPathSegment', () => {
it('returns the TestFlight URL segment for each platform', () => {
expect(AscApiUtils.testFlightPlatformPathSegment('IOS')).toBe('ios');
expect(AscApiUtils.testFlightPlatformPathSegment('TV_OS')).toBe('tvos');
expect(AscApiUtils.testFlightPlatformPathSegment('MAC_OS')).toBe('macos');
expect(AscApiUtils.testFlightPlatformPathSegment('VISION_OS')).toBe('visionos');
});
});
});
@@ -5,7 +5,6 @@ import path from 'path';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
import * as CompilationCache from '../../../ios/compilationCache';
export async function runFastlaneGym({
workingDir,
@@ -20,23 +19,14 @@ export async function runFastlaneGym({
env: BuildStepEnv;
extraEnv?: BuildStepEnv;
}): Promise<void> {
const workspacePath = path.join(workingDir, 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
// When caching is enabled, this also sets GYM_DERIVED_DATA_PATH so Fastlane
// and the cache restore/save operations use this exact directory.
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env,
logger,
});
const buildLogger = new XcodeBuildLogger(logger, workingDir);
void buildLogger.watchLogFiles(buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
cwd: path.join(workingDir, 'ios'),
logger,
env,
extraEnv: { ...extraEnv, ...compilationCacheEnv },
extraEnv,
});
} finally {
await buildLogger.flush();
@@ -9,6 +9,7 @@ import path from 'path';
import { z } from 'zod';
import { Sentry } from '../../../sentry';
import { getProxiedDownloadUrl } from '../../../utils/download';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
@@ -294,24 +295,6 @@ function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
@@ -10,6 +10,7 @@ import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { createServer } from 'node:net';
import { clearTimeout, setTimeout } from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
@@ -18,12 +19,16 @@ import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@latest';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '1280';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_MAX_DIMENSION = '960';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '3000000';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -144,77 +149,107 @@ export async function waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
maxDurationSeconds,
signal: cancelSignal,
idleTimeout,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
maxDurationSeconds?: number;
signal?: AbortSignal;
idleTimeout?: DeviceRunSessionIdleTimeout;
}): Promise<void> {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
if (idleTimeout) {
logger.info(
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
);
const durationAbortController = new AbortController();
const signal = cancelSignal
? AbortSignal.any([cancelSignal, durationAbortController.signal])
: durationAbortController.signal;
// Nothing to wait for if the step was already aborted before we started;
// return before logging so we don't claim to be polling a session we never poll.
if (signal.aborted) {
return;
}
let pollErrorCount = 0;
let lastActivityAt = new Date();
const durationTimeout =
maxDurationSeconds === undefined
? undefined
: setTimeout(() => {
logger.info(`Device run session ${deviceRunSessionId} reached its maximum duration.`);
durationAbortController.abort();
}, maxDurationSeconds * 1_000);
while (!signal?.aborted) {
try {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
if (durationTimeout !== undefined) {
logger.info(
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
);
}
if (idleTimeout) {
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
`Device run session ${deviceRunSessionId} had no activity for ` +
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
);
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
return;
}
logger.info(
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
);
}
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
let pollErrorCount = 0;
let lastActivityAt = new Date();
const status = result.data?.deviceRunSessions?.byId?.status;
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
pollErrorCount = 0;
if (status === 'STOPPED') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
if (err instanceof SystemError) {
throw err;
while (!signal.aborted) {
if (idleTimeout) {
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
`Device run session ${deviceRunSessionId} had no activity for ` +
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
);
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
return;
}
}
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
const error = err instanceof Error ? err : new Error(String(err));
pollErrorCount += 1;
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
logger.warn(
{ err: error, failedStatusPollCount: pollErrorCount },
'Could not poll device run session status; will retry.'
);
const status = result.data?.deviceRunSessions?.byId?.status;
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
pollErrorCount = 0;
if (status === 'STOPPED') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
if (err instanceof SystemError) {
throw err;
}
const error = err instanceof Error ? err : new Error(String(err));
pollErrorCount += 1;
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
logger.warn(
{ err: error, failedStatusPollCount: pollErrorCount },
'Could not poll device run session status; will retry.'
);
}
}
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
}
} finally {
if (durationTimeout !== undefined) {
clearTimeout(durationTimeout);
}
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
}
}
@@ -359,11 +394,12 @@ const TurnIceServersResponseSchema = z.object({
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
* intentionally expose the same ICE flag contract.
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
@@ -389,14 +425,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
* translate them into web preview CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
* Best-effort: on any failure we log and return [] so the preview server falls
* back to its built-in P2P/STUN behavior. The credential is passed as a process
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
* is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
@@ -425,9 +461,9 @@ export async function fetchServeSimTurnArgsAsync(
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
const args = turnIceServersToWebPreviewArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
@@ -435,7 +471,7 @@ export async function fetchServeSimTurnArgsAsync(
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
return [];
}
@@ -565,22 +601,32 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
return args;
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
SERVE_SIM_PACKAGE_SPEC,
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
'--host',
SERVE_SIM_HOST,
WEB_PREVIEW_HOST,
'--transport',
'webrtc',
'--webrtc-codec',
@@ -598,44 +644,84 @@ export function createServeSimArgs({
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
EXPO_DEVICE_HUB_MAX_DIMENSION,
'--video-bitrate',
EXPO_DEVICE_HUB_VIDEO_BITRATE,
'--video-fps',
EXPO_DEVICE_HUB_VIDEO_FPS,
'--hide-sidebar',
'--hide-boot-device',
...turnArgs,
];
}
async function findAvailablePortAsync(): Promise<number> {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, SERVE_SIM_HOST, () => resolve());
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
});
const address = server.address();
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
if (!address || typeof address === 'string') {
throw new SystemError('Could not allocate a local port for serve-sim.');
throw new SystemError('Could not allocate a local port for the web preview.');
}
return address.port;
}
const ServeSimReadyResponseSchema = z.object({
const WebPreviewReadyResponseSchema = z.object({
status: z.literal('ready'),
device: z.string(),
});
export async function waitForServeSimReadyAsync({
serveSim,
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
}: {
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
}): Promise<void> {
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
}`
);
}
try {
@@ -643,7 +729,7 @@ export async function waitForServeSimReadyAsync({
retries: 0,
timeout: 2_000,
});
ServeSimReadyResponseSchema.parse(await response.json());
WebPreviewReadyResponseSchema.parse(await response.json());
return;
} catch (error) {
lastError = error;
@@ -651,17 +737,75 @@ export async function waitForServeSimReadyAsync({
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim readiness at ${readyUrl}${
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
);
}
export type ServeSimPreviewHandle = {
export type DeviceWebPreviewHandle = {
previewUrl: string;
stopAsync: () => Promise<void>;
};
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
async function startWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
serverName,
packageSpec,
createArgs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
env,
});
try {
logger.info(`Waiting for ${serverName} to become ready.`);
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
}
}
},
};
} catch (error) {
await previewServer.stopAsync();
throw error;
}
}
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
@@ -669,47 +813,74 @@ export async function startServeSimWithTunnelAsync(
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const serveSim = spawnDetached({
command: 'npx',
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
});
}
try {
logger.info('Waiting for serve-sim to become ready.');
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'serve-sim',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
}
}
},
};
} catch (error) {
await serveSim.stopAsync();
throw error;
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'expo-device-hub',
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
});
}
export async function startDeviceWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
...options
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
}
}
@@ -6,11 +6,13 @@ import assert from 'assert';
import FastGlob from 'fast-glob';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import { retryAsync } from './retry';
/** Android Virtual Device is the device we run. */
@@ -305,21 +307,23 @@ export namespace AndroidEmulatorUtils {
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
emulatorOutputPath: string;
}> {
let logcatOutputPath: string;
let emulatorOutputPath: string;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
const timestamp = Math.floor(Date.now() / 1000)
.toString(16)
.padStart(8, '0');
logcatOutputPath = path.join(
logcatDirectory,
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
);
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(logcatOutputPath, '');
await fs.promises.writeFile(emulatorOutputPath, '');
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
cause: err,
});
}
@@ -346,7 +350,8 @@ export namespace AndroidEmulatorUtils {
],
{
detached: true,
stdio: 'inherit',
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
env: {
...env,
// We don't need to wait for emulator to exit gracefully.
@@ -354,6 +359,43 @@ export namespace AndroidEmulatorUtils {
},
}
);
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
}
});
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
// Piped stdio creates socket handles in this process which, unlike inherited
// file descriptors, keep the event loop alive for as long as the emulator runs.
// We never stop the emulator explicitly, so without unref-ing these the process
// would never exit on its own -- defeating the `detached` + `unref()` below.
// Output is still captured for as long as this process lives.
if (emulatorPromise.child.stdout instanceof Socket) {
emulatorPromise.child.stdout.unref();
}
if (emulatorPromise.child.stderr instanceof Socket) {
emulatorPromise.child.stderr.unref();
}
// If emulator fails to start, throw its error.
if (!emulatorPromise.child.pid) {
await emulatorPromise;
@@ -382,7 +424,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId, logcatOutputPath };
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
}
export async function waitForReadyAsync({
@@ -44,6 +44,32 @@ export namespace IosSimulatorUtils {
};
};
export type IosSimulatorRuntime = {
identifier: string;
isAvailable?: boolean;
version?: string;
};
type XcrunSimctlListRuntimesJsonOutput = {
runtimes: (Omit<IosSimulatorRuntime, 'identifier'> & { identifier?: string })[];
};
export async function getAvailableRuntimesAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<IosSimulatorRuntime[]> {
const { stdout } = await spawn('xcrun', ['simctl', 'list', 'runtimes', '--json'], { env });
const { runtimes } = JSON.parse(stdout) as XcrunSimctlListRuntimesJsonOutput;
return runtimes.flatMap(runtime =>
runtime.isAvailable !== false &&
runtime.identifier?.startsWith('com.apple.CoreSimulator.SimRuntime.iOS-')
? [{ ...runtime, identifier: runtime.identifier }]
: []
);
}
export async function getAvailableDevicesAsync({
env,
filter,
@@ -273,7 +273,7 @@ describe('AndroidEmulatorUtils', () => {
expect(attemptCounter).toBe(2);
}, 360_000);
it('captures native logcat output across an ADB server restart', async () => {
it('captures emulator and native logcat output across an ADB server restart', async () => {
const testLogcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
);
@@ -302,6 +302,21 @@ describe('AndroidEmulatorUtils', () => {
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
},
{
logger,
retryOptions: {
retries: 10,
retryIntervalMs: 1_000,
},
}
);
await spawn('adb', ['kill-server'], { env: process.env });
await spawn('adb', ['start-server'], { env: process.env });
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
@@ -1,10 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
@@ -17,6 +20,12 @@ jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
@@ -25,11 +34,13 @@ describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
temporaryDirectories = [];
jest.mocked(Sentry.capture).mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
afterEach(async () => {
jest.restoreAllMocks();
await Promise.all(
temporaryDirectories.map(async temporaryDirectory => {
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
@@ -39,7 +50,18 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
const child = { pid: 1234, unref: jest.fn() };
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
// narrows on before unref-ing them.
const stdout = new Socket();
const stderr = new Socket();
jest.spyOn(stdout, 'unref').mockReturnValue(stdout);
jest.spyOn(stderr, 'unref').mockReturnValue(stderr);
const child = Object.assign(new EventEmitter(), {
pid: 1234,
unref: jest.fn(),
stdout,
stderr,
});
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
spawnPromise.child = child;
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
@@ -57,11 +79,12 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('captures logcat through the emulator process', async () => {
it('captures logcat and emulator process output', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
const result = await AndroidEmulatorUtils.startAsync({
deviceName,
@@ -70,13 +93,21 @@ describe('AndroidEmulatorUtils', () => {
});
expect(result.logcatOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.log$`)
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
);
expect(result.emulatorOutputPath).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({ detached: true, stdio: 'inherit' })
expect.objectContaining({
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -84,6 +115,32 @@ describe('AndroidEmulatorUtils', () => {
expect.anything()
);
expect(child.unref).toHaveBeenCalled();
// Piped stdio would otherwise keep this process' event loop alive
// for as long as the emulator runs.
expect(child.stdout.unref).toHaveBeenCalled();
expect(child.stderr.unref).toHaveBeenCalled();
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
emulatorOutputStream.emit('error', writeError);
emulatorOutputStream.emit('error', writeError);
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android emulator process output',
writeError,
{
level: 'warning',
tags: { errorCode: 'ENOSPC' },
extras: {
deviceName,
emulatorOutputPath: result.emulatorOutputPath,
},
}
);
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
child.emit('close');
await emulatorOutputStreamClosed;
});
it('throws a SystemError when the staging directory cannot be prepared', async () => {
@@ -100,7 +157,7 @@ describe('AndroidEmulatorUtils', () => {
logcatDirectory: outputDir,
})
).rejects.toEqual(
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
new SystemError('Failed to prepare Android emulator output for eas-simulator.', {
cause: mkdirError,
})
);
@@ -23,6 +23,41 @@ describe('IosSimulatorUtils', () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
it('returns available iOS runtimes', async () => {
mockedSpawn.mockResolvedValue({
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
stderr: '',
} as any);
await expect(
IosSimulatorUtils.getAvailableRuntimesAsync({ env: process.env })
).resolves.toEqual([
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
]);
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'runtimes', '--json'], {
env: process.env,
});
});
});
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
it('adds a human-readable runtime display name', async () => {
mockedSpawn.mockResolvedValue({
@@ -0,0 +1,18 @@
import { Platform } from '@expo/eas-build-job';
import { getCcacheKeyPrefix } from '../cacheKey';
describe(getCcacheKeyPrefix, () => {
it('uses separate prefixes for iOS device and simulator builds', () => {
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: false })).toBe(
'ios-device-ccache-'
);
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: true })).toBe(
'ios-simulator-ccache-'
);
});
it('does not change the Android prefix', () => {
expect(getCcacheKeyPrefix({ platform: Platform.ANDROID })).toBe('android-ccache-');
});
});
@@ -0,0 +1,78 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
compressCocoapodsCacheAsync,
getCocoapodsCachePaths,
resolveCocoapodsCacheKeyAsync,
restoreCocoapodsCacheArchiveAsync,
} from '../cocoapodsCache';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('CocoaPods cache utilities', () => {
let workingDirectory: string;
const cocoapodsVersion = '1.16.2';
beforeEach(async () => {
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
});
afterEach(async () => {
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
});
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
await expect(
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
).resolves.toEqual({
key: 'ios-pods-1.16.2-',
keyPrefix: 'ios-pods-1.16.2-',
});
});
it('includes the Podfile.lock hash when it exists', async () => {
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
expect(secondResult.key).not.toBe(firstResult.key);
});
it('preserves symlinks and executable permissions through an archive round trip', async () => {
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
await fs.promises.chmod(scriptPath, 0o755);
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
const restoredScriptStat = await fs.promises.stat(scriptPath);
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
});
});
@@ -0,0 +1,27 @@
import { getProxiedDownloadUrl } from '../download';
describe(getProxiedDownloadUrl, () => {
it('returns null when no proxy base URL is set', () => {
expect(
getProxiedDownloadUrl({ directUrl: 'https://storage.googleapis.com/bucket/object' })
).toBeNull();
});
it('rewrites the origin to the proxy base URL', () => {
expect(
getProxiedDownloadUrl({
directUrl: 'https://storage.googleapis.com/bucket/object',
proxyBaseUrl: 'https://cache.example.com',
})
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
});
it('normalizes a proxy base URL with a trailing slash', () => {
expect(
getProxiedDownloadUrl({
directUrl: 'https://storage.googleapis.com/bucket/object',
proxyBaseUrl: 'https://cache.example.com/',
})
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
});
});
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: {},
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
});
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: {},
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
});
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${STAGING_VERSION}`],
extraEnv: { EXPO_STAGING: '1' },
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
extraEnv: {},
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
expect(spawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(stagingResult).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
});
process.env.ENVIRONMENT = 'production';
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(productionResult).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${PRODUCTION_VERSION}`],
extraEnv: {},
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
});
});
it('disables the minimum release age for every npx invocation', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
});
});
describe(runEasCliCommand, () => {
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
})
);
});
it('overrides a user-provided minimum release age', async () => {
process.env.ENVIRONMENT = 'production';
await runEasCliCommand({
args: ['deploy', '--json'],
options: {
cwd: '/tmp/project',
env: {
FOO: 'bar',
NPM_CONFIG_MIN_RELEASE_AGE: '365',
},
},
});
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
expect(spawnEnv).toEqual({
FOO: 'bar',
NPM_CONFIG_MIN_RELEASE_AGE: '0',
});
});
});
@@ -1,7 +1,7 @@
import resolveFrom from 'resolve-from';
import spawnAsync from '@expo/turtle-spawn';
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
import { ExpoCLIModuleNotFoundError, expoCommandAsync } from '../expoCli';
jest.mock('resolve-from', () => ({
__esModule: true,
@@ -0,0 +1,17 @@
import { formatSecondsForLog } from '../formatDuration';
describe(formatSecondsForLog, () => {
it.each([
[0, '0 seconds'],
[1, '1 second'],
[45, '45 seconds'],
[60, '1 minute'],
[90, '1 minute 30 seconds'],
[120, '2 minutes'],
[3600, '1 hour'],
[3661, '1 hour 1 minute 1 second'],
[7200, '2 hours'],
] as const)('formats %i as %s', (seconds, expected) => {
expect(formatSecondsForLog(seconds)).toBe(expected);
});
});
@@ -0,0 +1,43 @@
import { ChildProcess } from 'node:child_process';
import { isChildProcessAlive } from '../processes';
function child(partial: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
killed: boolean;
}): ChildProcess {
return partial as ChildProcess;
}
describe(isChildProcessAlive, () => {
it('is alive while the process is running', () => {
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: false }))).toBe(
true
);
});
it('is dead after a normal exit', () => {
expect(isChildProcessAlive(child({ exitCode: 0, signalCode: null, killed: false }))).toBe(
false
);
expect(isChildProcessAlive(child({ exitCode: 1, signalCode: null, killed: false }))).toBe(
false
);
});
it('is dead after an external signal termination (the regression case)', () => {
expect(
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGTERM', killed: false }))
).toBe(false);
expect(
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGKILL', killed: false }))
).toBe(false);
});
it('is dead once we have killed it', () => {
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: true }))).toBe(
false
);
});
});
@@ -0,0 +1,503 @@
import { SshSettings, SystemError } from '@expo/eas-build-job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
SshSessionHandle,
getSshIdleTimeoutSeconds,
getSshRelayServerUrl,
isSshEnabled,
startSshSessionAsync,
superviseSshSessionAsync,
} from '../turtleSshSession';
import { SshConnectionConfig, UptermHost, startUptermHostAsync } from '../upterm';
jest.mock('../retry', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../upterm', () => ({
...jest.requireActual('../upterm'),
startUptermHostAsync: jest.fn(),
}));
const mockedStartUptermHost = jest.mocked(startUptermHostAsync);
function sshJob(idleTimeoutSeconds: number): { ssh: SshSettings } {
return { ssh: { idleTimeoutSeconds, relayServerUrl: 'wss://relay.expo.dev' } };
}
function superviseHandle(
overrides: Partial<SshSessionHandle> &
Pick<SshSessionHandle, 'getConnectedClientCountAsync' | 'ensureConnectedAsync'>
): SshSessionHandle {
return {
stopAsync: async () => {},
...overrides,
};
}
describe(isSshEnabled, () => {
it('is true when job.ssh is present', () => {
expect(isSshEnabled(sshJob(0))).toBe(true);
});
it('is false otherwise', () => {
expect(isSshEnabled({})).toBe(false);
});
});
describe(getSshIdleTimeoutSeconds, () => {
it('returns idleTimeoutSeconds from job.ssh', () => {
expect(getSshIdleTimeoutSeconds(sshJob(900))).toBe(900);
expect(getSshIdleTimeoutSeconds(sshJob(0))).toBe(0);
expect(getSshIdleTimeoutSeconds(sshJob(3600))).toBe(3600);
});
it('defaults to 0 when job.ssh is missing', () => {
expect(getSshIdleTimeoutSeconds({})).toBe(0);
});
it('throws when the value is not an integer in range', () => {
for (const idleTimeoutSeconds of [300.5, -1, 3601]) {
expect(() => getSshIdleTimeoutSeconds(sshJob(idleTimeoutSeconds))).toThrow(SystemError);
}
});
});
describe(getSshRelayServerUrl, () => {
it('returns the relay url from the job ssh settings', () => {
expect(getSshRelayServerUrl(sshJob(0))).toBe('wss://relay.expo.dev');
});
it('throws when the job carries no relay url', () => {
expect(() => getSshRelayServerUrl({})).toThrow(/no relay server URL/);
});
});
describe(startSshSessionAsync, () => {
const config1: SshConnectionConfig = {
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'secret-1',
};
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
let createOrUpdateResult: { data?: unknown; error?: { message: string } };
let mutation: jest.Mock;
let ctx: BuildContext;
function makeHost(overrides: Partial<UptermHost> = {}): UptermHost {
const host: UptermHost = {
connectionConfig: config1,
getConnectedClientCountAsync: jest.fn().mockResolvedValue(3),
isAlive: jest.fn().mockReturnValue(true),
redialAsync: jest.fn(async () => {
host.connectionConfig = config2;
return config2;
}),
stopAsync: jest.fn().mockResolvedValue(undefined),
...overrides,
};
return host;
}
beforeEach(() => {
createOrUpdateResult = {
data: {
turtleSshSession: {
createOrUpdateTurtleSshSession: {
id: 'ts-1',
sessionSettings: { idleTimeoutSeconds: 900 },
},
},
},
};
mutation = jest.fn().mockReturnValue({ toPromise: async () => createOrUpdateResult });
ctx = {
logger: createMockLogger(),
graphqlClient: { mutation },
} as unknown as BuildContext;
});
it('creates or updates the session and returns a handle plus idle timeout that delegates to the host', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
const { handle, idleTimeoutSeconds } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://relay.expo.dev',
idleTimeoutSeconds: 900,
});
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 900 },
});
expect(idleTimeoutSeconds).toBe(900);
await handle.getConnectedClientCountAsync();
expect(host.getConnectedClientCountAsync).toHaveBeenCalled();
await handle.stopAsync();
expect(host.stopAsync).toHaveBeenCalled();
});
it('reports a build target', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
await startSshSessionAsync(ctx, {
target: { type: 'BUILD', id: 'b-1' },
relayServerUrl: 'wss://relay.expo.dev',
idleTimeoutSeconds: 900,
});
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'BUILD', id: 'b-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 900 },
});
});
it('throws and tears down the dialed host when creating or updating the session fails', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = { error: { message: 'boom' } };
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow('boom');
expect(host.stopAsync).toHaveBeenCalled();
});
it('still rethrows when teardown after a create/update failure also fails', async () => {
const host = makeHost({
stopAsync: jest.fn().mockRejectedValue(new Error('already gone')),
});
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = { error: { message: 'boom' } };
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow('boom');
expect(host.stopAsync).toHaveBeenCalled();
});
it('throws "no data returned" when create or update succeeds without a payload', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = {};
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow(/no data returned/);
expect(host.stopAsync).toHaveBeenCalled();
});
it('ensureConnected is a no-op while the host is alive', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(true) });
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(1);
});
it('marks reconnecting, redials, then reports the fresh config', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
connectionConfig: expect.objectContaining({ reconnecting: true }),
})
);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
it('continues redial when the reconnecting status update fails', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
mockedStartUptermHost.mockResolvedValue(host);
mutation
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult }) // initial create
.mockReturnValueOnce({
toPromise: async () => {
throw new Error('www unreachable');
},
}) // reconnecting=true best-effort
.mockReturnValue({ toPromise: async () => createOrUpdateResult }); // post-redial
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
});
it('retries only the report (no second redial) when reporting the fresh config fails', async () => {
let alive = false;
const host = makeHost({
isAlive: jest.fn(() => alive),
});
host.redialAsync = jest.fn(async () => {
alive = true;
host.connectionConfig = config2;
return config2;
});
mockedStartUptermHost.mockResolvedValue(host);
mutation = jest
.fn()
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'flaky' } }) })
.mockReturnValue({ toPromise: async () => createOrUpdateResult });
ctx = { logger: createMockLogger(), graphqlClient: { mutation } } as unknown as BuildContext;
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
it('throws once the redial budget is exhausted', async () => {
const host = makeHost({
isAlive: jest.fn().mockReturnValue(false),
redialAsync: jest.fn().mockRejectedValue(new Error('relay down')),
});
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await expect(handle.ensureConnectedAsync()).rejects.toThrow(/after 10 attempts/);
expect(host.redialAsync).toHaveBeenCalledTimes(10);
});
});
describe(superviseSshSessionAsync, () => {
const logger = createMockLogger();
const mockedSleep = jest.mocked(sleepAsync);
beforeEach(() => {
jest.clearAllMocks();
mockedSleep.mockResolvedValue(undefined);
});
it('keeps the session open while the job is running, even with a 0 idle timeout', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
if (polls >= 5) {
throw new SystemError('stop the loop');
}
},
getConnectedClientCountAsync: async () => {
polls += 1;
return 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => false,
logger,
});
expect(polls).toBe(5);
});
it('closes as soon as the job finishes when the idle timeout is 0', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
return 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'The job finished and no SSH client is connected. Closing the session.'
);
});
it('waits for a connected client to disconnect before closing with a 0 idle timeout', async () => {
const clientCounts = [1, 1, 0];
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
return clientCounts.shift() ?? 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBe(3);
});
it('starts the idle countdown when the job finishes, not when the session opens', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
let jobHasFinished = false;
let polls = 0;
mockedSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 60_000);
});
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
if (polls >= 5) {
jobHasFinished = true;
}
return 0;
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => jobHasFinished,
logger,
});
// Nobody connects, so the 4 idle minutes spent during the job must not count towards the
// 5 minute timeout: it closes 5 minutes after the job finished, on the 10th poll.
expect(polls).toBe(10);
} finally {
jest.useRealTimers();
}
});
it('logs the connected client count whenever it changes', async () => {
const clientCounts = [1, 2, 1, 0];
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => clientCounts.shift() ?? 0,
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 2');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 0');
});
it('closes the session when the relay connection cannot be restored', async () => {
let clientCountCalls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
throw new SystemError('relay down');
},
getConnectedClientCountAsync: async () => {
clientCountCalls += 1;
return 1;
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => false,
logger,
});
expect(clientCountCalls).toBe(0);
});
it('does not close while a client stays connected, even past the idle timeout', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
if (polls > 3) {
throw new SystemError('stop the loop');
}
},
getConnectedClientCountAsync: async () => {
polls += 1;
return 1;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBeGreaterThan(3);
});
it('closes when the client count cannot be read', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
throw new SystemError(
'Could not read the SSH client count from the upterm admin socket.'
);
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => false,
logger,
});
expect(polls).toBe(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Could not read the SSH client count. Closing the session.'
);
});
});
@@ -0,0 +1,466 @@
import { SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
connectionConfigFromUptermSession,
redactConnectionSecrets,
redactSpawnErrorForLog,
resolveUptermGcsObjectName,
startUptermHostAsync,
} from '../upterm';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/downloader', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('node:fs/promises', () => ({
__esModule: true,
default: {
access: jest.fn(),
mkdir: jest.fn(),
chmod: jest.fn(),
mkdtemp: jest.fn(),
writeFile: jest.fn(),
rm: jest.fn(),
readdir: jest.fn(),
},
}));
describe(resolveUptermGcsObjectName, () => {
it('maps worker platforms to GCS object names', () => {
expect(resolveUptermGcsObjectName('darwin', 'arm64')).toBe('upterm-darwin-arm64');
expect(resolveUptermGcsObjectName('linux', 'x64')).toBe('upterm-linux-amd64');
});
it('rejects unsupported platforms', () => {
expect(() => resolveUptermGcsObjectName('darwin', 'x64')).toThrow(/only available/);
expect(() => resolveUptermGcsObjectName('linux', 'arm64')).toThrow(/only available/);
});
});
describe(connectionConfigFromUptermSession, () => {
it('parses sessionId and host from session current fields', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'TOKENabc123',
host: 'ssh://relay.expo.dev:22',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENabc123',
});
});
it('accepts a bare hostname', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
{
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'tok',
}
);
});
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'tok',
host: 'ssh://relay.expo.dev:2222',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev:2222',
secret: 'tok',
});
});
it('returns null when the host is not a parseable url', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
});
});
describe(redactConnectionSecrets, () => {
it('strips the userinfo secret from a connection string', () => {
expect(
redactConnectionSecrets('Connect: upterm proxy wss://[email protected] now')
).toBe('Connect: upterm proxy wss://<redacted>@relay.expo.dev now');
});
it('strips the bare token that trails the ssh ProxyCommand line', () => {
const line =
"ssh -o ProxyCommand='upterm proxy wss://[email protected]' [email protected]";
const redacted = redactConnectionSecrets(line);
expect(redacted).not.toContain('TOKENabc123');
expect(redacted).toBe(
"ssh -o ProxyCommand='upterm proxy wss://<redacted>@uptermd.upterm.dev' <redacted>@uptermd.upterm.dev"
);
});
it('strips the bare token for ws:// relay URLs too', () => {
const line =
"ssh -o ProxyCommand='upterm proxy ws://[email protected]' [email protected]";
const redacted = redactConnectionSecrets(line);
expect(redacted).not.toContain('TOKENabc123');
expect(redacted).toBe(
"ssh -o ProxyCommand='upterm proxy ws://<redacted>@relay.local' <redacted>@relay.local"
);
});
it('leaves output without userinfo untouched', () => {
expect(redactConnectionSecrets('dialing wss://relay.expo.dev')).toBe(
'dialing wss://relay.expo.dev'
);
});
it('strips control characters (incl. ESC) while keeping newlines', () => {
// ESC alone is enough to neutralize CSI; leftover printable "[31m" is harmless in logs.
expect(redactConnectionSecrets('ok\n\u001b[31mfail\u001b[0m\r\nbad\u0007')).toBe(
'ok\n[31mfail[0m\nbad'
);
});
});
describe(redactSpawnErrorForLog, () => {
it('redacts secrets in message, stdout, and stderr', () => {
expect(
redactSpawnErrorForLog({
message: 'failed wss://[email protected]',
stdout: 'upterm proxy wss://[email protected]',
stderr: 'ssh dial error: wss://[email protected]',
code: 1,
})
).toEqual({
message: 'failed wss://<redacted>@relay.expo.dev',
stdout: 'upterm proxy wss://<redacted>@relay.expo.dev',
stderr: 'ssh dial error: wss://<redacted>@relay.expo.dev',
code: 1,
});
});
it('passes through non-objects', () => {
expect(redactSpawnErrorForLog('boom')).toBe('boom');
expect(redactSpawnErrorForLog(null)).toBeNull();
});
it('leaves non-string message/stdout/stderr fields alone', () => {
expect(
redactSpawnErrorForLog({
message: 42,
stdout: Buffer.from('x'),
code: 1,
})
).toEqual({
message: 42,
stdout: Buffer.from('x'),
code: 1,
});
});
});
describe(startUptermHostAsync, () => {
const mockedSpawn = jest.mocked(spawn);
const mockedSleep = jest.mocked(sleepAsync);
const mockedFs = jest.mocked(fs);
const mockedDownloadFile = jest.mocked(downloadFile);
let hostProcess: {
child: {
stdout: { on: (event: string, cb: (chunk: Buffer) => void) => void };
stderr: { on: jest.Mock };
pid: number;
kill: jest.Mock;
unref: jest.Mock;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
killed: boolean;
};
catch: (cb: (err: unknown) => void) => Promise<void>;
};
let sessionStdout: string;
let uptermOnPath = true;
function makeHostProcess(): typeof hostProcess {
return {
child: {
stdout: {
on: jest.fn((_event: string, cb: (chunk: Buffer) => void) => {
cb(Buffer.from('host output'));
}),
},
stderr: { on: jest.fn() },
pid: 4242,
kill: jest.fn(),
unref: jest.fn(),
exitCode: null,
signalCode: null,
killed: false,
},
catch: cb => {
cb(new Error('exited'));
return Promise.resolve();
},
};
}
function makeCtx(env: Record<string, string> = {}): BuildContext {
return {
logger: createMockLogger(),
env,
} as unknown as BuildContext;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(process, 'kill').mockReturnValue(true);
hostProcess = makeHostProcess();
uptermOnPath = true;
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'ssh://relay.expo.dev:22',
clientCount: 0,
});
mockedSleep.mockResolvedValue(undefined);
mockedFs.mkdir.mockResolvedValue(undefined as never);
mockedFs.chmod.mockResolvedValue(undefined as never);
mockedFs.mkdtemp.mockImplementation((async (prefix: string) =>
String(prefix).includes('eas-upterm') ? '/tmp/eas-upterm-1' : '/tmp/eas-ssh-1') as never);
mockedFs.writeFile.mockResolvedValue(undefined as never);
mockedFs.rm.mockResolvedValue(undefined as never);
mockedFs.readdir.mockResolvedValue(['default.sock'] as never);
mockedDownloadFile.mockResolvedValue(undefined as never);
mockedSpawn.mockImplementation(((cmd: string, args: string[]) => {
if (cmd === 'upterm' && Array.isArray(args) && args[0] === 'version') {
if (!uptermOnPath) {
return Promise.reject(new Error('not found')) as never;
}
return Promise.resolve({ stdout: 'upterm version 0.24.0\n', stderr: '' }) as never;
}
if (Array.isArray(args) && args[0] === 'host') {
return hostProcess as never;
}
if (Array.isArray(args) && args[0] === 'session') {
return Promise.resolve({ stdout: sessionStdout, stderr: '' }) as never;
}
return Promise.resolve({ stdout: '', stderr: '' }) as never;
}) as never);
});
it('uses upterm on PATH, dials, and returns a handle with the parsed config', async () => {
const env = { PATH: '/job/bin', CUSTOM: '1' };
const host = await startUptermHostAsync(makeCtx(env), {
relayServerUrl: 'wss://relay.expo.dev',
});
expect(host.connectionConfig).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.isAlive()).toBe(true);
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
stdio: 'pipe',
env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'upterm',
expect.arrayContaining(['host']),
expect.objectContaining({ env: expect.objectContaining({ CUSTOM: '1', PATH: '/job/bin' }) })
);
});
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
uptermOnPath = false;
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
expect(host.connectionConfig.secret).toBe('TOKENx');
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringMatching(/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
expect.stringContaining('/tmp/eas-upterm-1'),
expect.objectContaining({ retry: 3 })
);
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringContaining('/tmp/eas-upterm-1'),
expect.arrayContaining(['host']),
expect.anything()
);
});
it('throws when upterm is missing from PATH and the GCS download fails', async () => {
uptermOnPath = false;
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/could not be downloaded/
);
});
it('still throws when download fails with a non-Error and temp cleanup also fails', async () => {
uptermOnPath = false;
mockedDownloadFile.mockRejectedValue('network down' as never);
mockedFs.rm.mockRejectedValue(new Error('EPERM') as never);
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/network down/
);
});
it('tears down and rethrows when the host never registers with the relay', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
mockedFs.readdir.mockResolvedValue([] as never);
mockedSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 61_000);
});
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/did not register/
);
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
} finally {
jest.useRealTimers();
}
});
it('stopAsync kills the process and removes the state dir', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
await host.stopAsync();
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
});
it('falls back to child.kill when process.kill on the process group fails', async () => {
jest.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('ESRCH');
});
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
await host.stopAsync();
expect(hostProcess.child.kill).toHaveBeenCalled();
});
it('skips process.kill when the host process has no pid', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.child.pid = undefined as unknown as number;
await host.stopAsync();
expect(process.kill).not.toHaveBeenCalled();
expect(hostProcess.child.kill).not.toHaveBeenCalled();
});
it('redialAsync re-establishes the connection', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
const config = await host.redialAsync();
expect(config).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.connectionConfig).toEqual(config);
});
it('redialAsync waits for the old process to exit before clearing its socket dir', async () => {
const order: string[] = [];
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
// Keep the give-up timer pending so only the process exit can unblock the redial.
mockedSleep.mockImplementation(() => new Promise<void>(() => {}));
let resolveExit: (() => void) | undefined;
hostProcess.catch = () =>
new Promise<void>(resolve => {
resolveExit = () => {
order.push('exited');
resolve();
};
});
mockedFs.rm.mockImplementation((() => {
order.push('rm');
return Promise.resolve(undefined);
}) as never);
jest.spyOn(process, 'kill').mockImplementation(() => {
order.push('kill');
// A real SIGTERM is asynchronous, so the exit lands after the kill returns.
setImmediate(() => resolveExit?.());
return true;
});
await host.redialAsync();
expect(order).toEqual(['kill', 'exited', 'rm']);
});
it('redialAsync gives up waiting when the old process does not exit', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.catch = () => new Promise<void>(() => {});
await expect(host.redialAsync()).resolves.toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(mockedSleep).toHaveBeenCalledWith(5_000);
});
it('continues when the previous socket dir cannot be cleared', async () => {
mockedFs.rm.mockImplementation(((dir: string) =>
dir === '/tmp/eas-ssh-1/upterm'
? Promise.reject(new Error('busy'))
: Promise.resolve(undefined)) as never);
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
expect(host.connectionConfig.secret).toBe('TOKENx');
});
describe('getConnectedClientCountAsync', () => {
it('throws when no admin socket exists yet (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedFs.readdir.mockResolvedValue([] as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('returns the client count reported by the admin socket', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'relay.expo.dev',
clientCount: 4,
});
expect(await host.getConnectedClientCountAsync()).toBe(4);
});
it('throws when the count query fails (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedSpawn.mockImplementation((() => Promise.reject(new Error('socket gone'))) as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('throws when the reported count is not a number (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'relay.expo.dev',
clientCount: 'nope',
});
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('throws when the socket dir cannot be read (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedFs.readdir.mockRejectedValue(new Error('no dir') as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
});
});
+14 -7
View File
@@ -7,17 +7,24 @@ import path from 'path';
import { findPackagerRootDir } from './packageManager';
const IOS_CACHE_KEY_PREFIX = 'ios-ccache-';
const IOS_DEVICE_CACHE_KEY_PREFIX = 'ios-device-ccache-';
const IOS_SIMULATOR_CACHE_KEY_PREFIX = 'ios-simulator-ccache-';
const ANDROID_CACHE_KEY_PREFIX = 'android-ccache-';
const PUBLIC_IOS_CACHE_KEY_PREFIX = 'public-ios-ccache-';
const PUBLIC_ANDROID_CACHE_KEY_PREFIX = 'public-android-ccache-';
const DARWIN_CACHE_PATH = 'Library/Caches/ccache';
const LINUX_CACHE_PATH = '.cache/ccache';
export const CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX,
[Platform.IOS]: IOS_CACHE_KEY_PREFIX,
};
export type CcacheBuildTarget =
| { platform: Platform.ANDROID }
| { platform: Platform.IOS; simulator: boolean };
export function getCcacheKeyPrefix(target: CcacheBuildTarget): string {
if (target.platform === Platform.IOS) {
return target.simulator ? IOS_SIMULATOR_CACHE_KEY_PREFIX : IOS_DEVICE_CACHE_KEY_PREFIX;
}
return ANDROID_CACHE_KEY_PREFIX;
}
export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX,
@@ -36,7 +43,7 @@ export function getCcachePath(env: Record<string, string | undefined>): string {
export async function generateDefaultBuildCacheKeyAsync(
workingDirectory: string,
platform: Platform
target: CcacheBuildTarget
): Promise<string> {
// This will resolve which package manager and use the relevant lock file
// The lock file hash is the key and ensures cache is fresh
@@ -45,7 +52,7 @@ export async function generateDefaultBuildCacheKeyAsync(
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`;
return `${getCcacheKeyPrefix(target)}${hashFiles([lockPath])}`;
} catch (err: any) {
throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`);
}
@@ -0,0 +1,106 @@
import { hashFiles } from '@expo/steps';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as tar from 'tar';
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
const PODS_DIRECTORY_NAME = 'Pods';
const PODFILE_LOCK_NAME = 'Podfile.lock';
export function getCocoapodsCachePaths(workingDirectory: string): {
iosDirectory: string;
podsDirectory: string;
podfileLockPath: string;
} {
const iosDirectory = path.join(workingDirectory, 'ios');
return {
iosDirectory,
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
};
}
export async function resolveCocoapodsCacheKeyAsync(
workingDirectory: string,
cocoapodsVersion: string
): Promise<{ key: string; keyPrefix: string }> {
const normalizedVersion = cocoapodsVersion.trim();
if (!normalizedVersion) {
throw new Error('Failed to determine CocoaPods version');
}
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
try {
await fs.promises.access(podfileLockPath);
} catch {
return { key: keyPrefix, keyPrefix };
}
return {
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
keyPrefix,
};
}
export async function compressCocoapodsCacheAsync({
workingDirectory,
}: {
workingDirectory: string;
}): Promise<{ archivePath: string }> {
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
await fs.promises.access(podsDirectory);
const archiveDestinationDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'save-cocoapods-cache-')
);
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
await tar.create(
{
file: archivePath,
cwd: iosDirectory,
gzip: true,
},
[PODS_DIRECTORY_NAME]
);
return { archivePath };
}
export async function restoreCocoapodsCacheArchiveAsync({
archivePath,
workingDirectory,
}: {
archivePath: string;
workingDirectory: string;
}): Promise<void> {
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
await fs.promises.mkdir(iosDirectory, { recursive: true });
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
path.join(iosDirectory, '.eas-pods-cache-')
);
try {
await tar.extract({
file: archivePath,
cwd: temporaryRestoreDirectory,
filter: entryPath =>
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
});
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
if (!restoredPodsStat.isDirectory()) {
throw new Error('CocoaPods cache archive does not contain a Pods directory');
}
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
} finally {
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
}
}
@@ -0,0 +1,17 @@
export function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
+5 -3
View File
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
/**
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: {},
extraEnv: NPX_ENV,
};
} else if (process.env.ENVIRONMENT === 'staging') {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
};
} else {
const versions = await resolveEasCliVersionsAsync();
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
extraEnv: {},
extraEnv: NPX_ENV,
};
}
}
@@ -0,0 +1,16 @@
export function formatSecondsForLog(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(hours === 1 ? '1 hour' : `${hours} hours`);
}
if (minutes > 0) {
parts.push(minutes === 1 ? '1 minute' : `${minutes} minutes`);
}
if (seconds > 0 || parts.length === 0) {
parts.push(seconds === 1 ? '1 second' : `${seconds} seconds`);
}
return parts.join(' ');
}
@@ -1,5 +1,26 @@
import { ChildProcess } from 'node:child_process';
import spawn from '@expo/turtle-spawn';
export function isChildProcessAlive(child: ChildProcess): boolean {
return child.exitCode === null && child.signalCode === null && !child.killed;
}
/**
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
*/
export function killProcessGroup(child: ChildProcess): void {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
}
}
async function getChildrenPidsAsync(parentPids: number[]): Promise<number[]> {
try {
const result = await spawn('pgrep', ['-P', parentPids.join(',')], {
@@ -0,0 +1,250 @@
import { Job, SshSettings, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import { formatSecondsForLog } from './formatDuration';
import { sleepAsync } from './retry';
import { SshConnectionConfig, startUptermHostAsync } from './upterm';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
const MAX_SSH_REDIALS = 10;
const REDIAL_BACKOFF_MS = 6_000;
const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
const CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION = graphql(`
mutation CreateOrUpdateTurtleSshSession(
$target: TurtleSshTargetInput!
$connectionConfig: TurtleSshConnectionConfigInput!
$sessionSettings: TurtleSshSessionSettingsInput!
) {
turtleSshSession {
createOrUpdateTurtleSshSession(
target: $target
connectionConfig: $connectionConfig
sessionSettings: $sessionSettings
) {
id
sessionSettings {
idleTimeoutSeconds
}
}
}
}
`);
export type TurtleSshTarget = { type: 'BUILD' | 'JOB_RUN'; id: string };
export function isSshEnabled(job: Pick<Job, 'ssh'>): boolean {
return job.ssh != null;
}
export function getSshIdleTimeoutSeconds(job: Pick<Job, 'ssh'>): number {
const idleTimeoutSeconds = job.ssh?.idleTimeoutSeconds ?? DEFAULT_SSH_IDLE_TIMEOUT_SECONDS;
if (
!Number.isInteger(idleTimeoutSeconds) ||
idleTimeoutSeconds < 0 ||
idleTimeoutSeconds > MAX_SSH_IDLE_TIMEOUT_SECONDS
) {
throw new SystemError(
`SSH idle timeout must be an integer between 0 and ${MAX_SSH_IDLE_TIMEOUT_SECONDS} seconds, got ${idleTimeoutSeconds}.`,
{ trackingCode: 'SSH_IDLE_TIMEOUT_INVALID' }
);
}
return idleTimeoutSeconds;
}
export function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string {
const relayServerUrl = job.ssh?.relayServerUrl;
if (!relayServerUrl) {
throw new SystemError('SSH is enabled but no relay server URL was configured on the job.', {
trackingCode: 'SSH_RELAY_SERVER_URL_MISSING',
});
}
return relayServerUrl;
}
export type SshSessionHandle = {
getConnectedClientCountAsync: () => Promise<number>;
ensureConnectedAsync: () => Promise<void>;
stopAsync: () => Promise<void>;
};
export type StartedSshSession = {
handle: SshSessionHandle;
idleTimeoutSeconds: number;
};
async function createOrUpdateSessionAsync(
ctx: BuildContext,
{
target,
connectionConfig,
idleTimeoutSeconds,
}: {
target: TurtleSshTarget;
connectionConfig: SshConnectionConfig & { reconnecting?: boolean };
idleTimeoutSeconds: number;
}
): Promise<{ idleTimeoutSeconds: number }> {
const result = await ctx.graphqlClient
.mutation(CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION, {
target,
connectionConfig: {
...connectionConfig,
type: 'UPTERM_V1',
},
sessionSettings: { idleTimeoutSeconds },
})
.toPromise();
if (result.error || !result.data) {
throw new SystemError(
`Failed to create or update the SSH session: ${result.error?.message ?? 'no data returned'}`,
{ cause: result.error }
);
}
const session = result.data.turtleSshSession.createOrUpdateTurtleSshSession;
return { idleTimeoutSeconds: session.sessionSettings.idleTimeoutSeconds };
}
export async function startSshSessionAsync(
ctx: BuildContext,
{
target,
relayServerUrl,
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}: {
target: TurtleSshTarget;
} & SshSettings
): Promise<StartedSshSession> {
const logger = ctx.logger;
const host = await startUptermHostAsync(ctx, { relayServerUrl });
let idleTimeoutSeconds: number;
try {
({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: false },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}));
} catch (err) {
await host.stopAsync().catch(() => {});
throw err;
}
const ensureConnectedAsync = async (): Promise<void> => {
if (host.isAlive()) {
return;
}
for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
try {
if (!host.isAlive()) {
logger.warn('The SSH relay connection dropped. Reconnecting...');
await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: true },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}).catch(() => {});
await host.redialAsync();
}
await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: false },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
});
logger.info('The SSH relay connection was restored.');
return;
} catch (err) {
logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
if (attempt < MAX_SSH_REDIALS) {
await sleepAsync(REDIAL_BACKOFF_MS);
}
}
}
throw new SystemError(
`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`
);
};
return {
handle: {
getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
ensureConnectedAsync,
stopAsync: () => host.stopAsync(),
},
idleTimeoutSeconds,
};
}
export async function superviseSshSessionAsync({
handle,
idleTimeoutSeconds,
hasJobFinished,
logger,
}: {
handle: SshSessionHandle;
idleTimeoutSeconds: number;
hasJobFinished: () => boolean;
logger: bunyan;
}): Promise<void> {
const idleTimeoutMs = idleTimeoutSeconds * 1_000;
let idleSince: number | null = null;
let previousClientCount = 0;
for (;;) {
try {
await handle.ensureConnectedAsync();
} catch (err) {
logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
Sentry.capture(
'Could not restore the SSH relay connection',
err instanceof Error ? err : undefined,
{
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
}
);
return;
}
let connectedClientCount: number;
try {
connectedClientCount = await handle.getConnectedClientCountAsync();
} catch (err) {
logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
Sentry.capture(
'Could not read the SSH client count',
err instanceof Error ? err : undefined,
{
tags: { trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE' },
}
);
return;
}
if (connectedClientCount !== previousClientCount) {
logger.info(`SSH clients connected: ${connectedClientCount}`);
previousClientCount = connectedClientCount;
}
const jobHasFinished = hasJobFinished();
if (connectedClientCount > 0 || !jobHasFinished) {
idleSince = null;
} else if (idleSince === null) {
idleSince = Date.now();
}
if (jobHasFinished && connectedClientCount === 0) {
if (idleTimeoutSeconds === 0) {
logger.info('The job finished and no SSH client is connected. Closing the session.');
return;
}
if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) {
logger.info(
`No SSH client connected for ${formatSecondsForLog(idleTimeoutSeconds)} after the job finished. Closing the session.`
);
return;
}
}
await sleepAsync(CLIENT_COUNT_POLL_INTERVAL_MS);
}
}
+318
View File
@@ -0,0 +1,318 @@
import { Env, SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import { isChildProcessAlive, killProcessGroup } from './processes';
import { sleepAsync } from './retry';
import { BuildContext } from '../context';
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
export function resolveUptermGcsObjectName(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch
): string {
if (platform === 'darwin' && arch === 'arm64') {
return 'upterm-darwin-arm64';
}
if (platform === 'linux' && arch === 'x64') {
return 'upterm-linux-amd64';
}
throw new SystemError(
`SSH upterm is only available on darwin/arm64 and linux/x64 (got ${platform}/${arch}).`
);
}
const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
const CONNECTION_POLL_INTERVAL_MS = 500;
const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
const CLIENT_COUNT_READ_ATTEMPTS = 4;
const CLIENT_COUNT_READ_RETRY_MS = 500;
const DEFAULT_SSH_PORT = '22';
export type SshConnectionConfig = {
type: 'upterm-v1';
host: string;
secret: string;
};
export type UptermHost = {
connectionConfig: SshConnectionConfig;
getConnectedClientCountAsync: () => Promise<number>;
isAlive: () => boolean;
redialAsync: () => Promise<SshConnectionConfig>;
stopAsync: () => Promise<void>;
};
const UptermSessionJsonZ = z.object({
sessionId: z.string().min(1),
host: z.string().min(1),
clientCount: z.number().optional(),
});
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
export function connectionConfigFromUptermSession(
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
): SshConnectionConfig | null {
let host = parsed.host;
if (host.includes('://')) {
let url: URL;
try {
url = new URL(host);
} catch {
return null;
}
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
}
if (!host) {
return null;
}
return { type: 'upterm-v1', host, secret: parsed.sessionId };
}
/**
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
* prints the session id as the userinfo of the connect line it advertises
* (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
* same line. Control characters are dropped first so they cannot split a token mid-match. The
* loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
* blanks userinfo in any other URL as a catch-all.
*/
export function redactConnectionSecrets(text: string): string {
let redacted = text.replace(CONTROL_CHARACTERS, '');
for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
redacted = redacted.split(token).join('<redacted>');
}
return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
}
export function redactSpawnErrorForLog(err: unknown): unknown {
if (!err || typeof err !== 'object') {
return err;
}
const spawnErr = err as { message?: unknown; stdout?: unknown; stderr?: unknown };
return {
...spawnErr,
...(typeof spawnErr.message === 'string'
? { message: redactConnectionSecrets(spawnErr.message) }
: {}),
...(typeof spawnErr.stdout === 'string'
? { stdout: redactConnectionSecrets(spawnErr.stdout) }
: {}),
...(typeof spawnErr.stderr === 'string'
? { stderr: redactConnectionSecrets(spawnErr.stderr) }
: {}),
};
}
export async function resolveUptermPathAsync(env: Env): Promise<string> {
try {
await spawn('upterm', ['version'], { stdio: 'pipe', env });
return 'upterm';
} catch {}
const objectName = resolveUptermGcsObjectName();
const downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-upterm-'));
const uptermPath = path.join(downloadDir, objectName);
const url = `${UPTERM_GCS_BASE_URL}/${objectName}`;
try {
await downloadFile(url, uptermPath, { retry: 3, timeout: UPTERM_DOWNLOAD_TIMEOUT_MS });
await fs.chmod(uptermPath, 0o755);
} catch (err) {
await fs.rm(downloadDir, { recursive: true, force: true }).catch(() => {});
throw new SystemError(
`The upterm SSH client was not on PATH and could not be downloaded from ${url}. ${
err instanceof Error ? err.message : String(err)
}`
);
}
return uptermPath;
}
async function findAdminSocketPathAsync(uptermSocketDir: string): Promise<string | null> {
// Use this dial's own admin socket, not upterm's default, which can still point at a previous
// dial's session after a redial and break client-count reads.
const entries = await fs.readdir(uptermSocketDir).catch(() => [] as string[]);
const socketName = entries.find(entry => entry.endsWith('.sock'));
return socketName ? path.join(uptermSocketDir, socketName) : null;
}
async function readCurrentSessionJsonAsync(
uptermPath: string,
adminSocketPath: string
): Promise<UptermSessionJson | null> {
try {
const result = await spawn(
uptermPath,
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
{ stdio: 'pipe' }
);
const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
async function waitForConnectionConfigAsync(
uptermPath: string,
uptermSocketDir: string,
getHostOutput: () => string
): Promise<SshConnectionConfig> {
const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
for (;;) {
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
if (adminSocketPath) {
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
if (session) {
const connectionConfig = connectionConfigFromUptermSession(session);
if (connectionConfig) {
return connectionConfig;
}
}
}
if (Date.now() >= deadline) {
throw new SystemError(
`The upterm client did not register with the relay within ${
CONNECTION_STARTUP_TIMEOUT_MS / 1_000
}s. Output:\n${redactConnectionSecrets(getHostOutput())}`
);
}
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
}
}
export async function startUptermHostAsync(
ctx: BuildContext,
{ relayServerUrl }: { relayServerUrl: string }
): Promise<UptermHost> {
const uptermPath = await resolveUptermPathAsync(ctx.env);
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-ssh-'));
const hostKeyPath = path.join(stateDir, 'id_host');
const forceCommandPath = path.join(stateDir, 'join.sh');
const uptermSocketDir = path.join(stateDir, 'upterm');
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
logger: ctx.logger,
});
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
let currentProcess: SpawnPromise<SpawnResult> | null = null;
const stopCurrentProcessAsync = async (): Promise<void> => {
const previousProcess = currentProcess;
currentProcess = null;
if (!previousProcess) {
return;
}
killProcessGroup(previousProcess.child);
await Promise.race([
previousProcess.catch(() => {}),
sleepAsync(PROCESS_EXIT_TIMEOUT_MS).then(() => {
ctx.logger.debug('The previous upterm host process did not exit in time.');
}),
]);
};
const getConnectedClientCountAsync = async (): Promise<number> => {
for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
const session = adminSocketPath
? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
: null;
if (session && typeof session.clientCount === 'number') {
return session.clientCount;
}
if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
await sleepAsync(CLIENT_COUNT_READ_RETRY_MS);
}
}
throw new SystemError('Could not read the SSH client count from the upterm admin socket.', {
trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE',
});
};
const dialAsync = async (): Promise<SshConnectionConfig> => {
await stopCurrentProcessAsync();
await fs.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
});
ctx.logger.debug('Connecting to the SSH relay.');
// --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
// `--` is the host-side process that keeps `upterm host` up while nobody is connected.
const uptermProcess = spawn(
uptermPath,
[
'host',
'--server',
relayServerUrl,
'--accept',
'--skip-host-key-check',
'-i',
hostKeyPath,
'--force-command',
forceCommandPath,
'--',
'bash',
'-lc',
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
],
{
// upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
// find it for `session current` and clean it up on stop/redial.
env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
}
);
uptermProcess.catch(err =>
ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.')
);
uptermProcess.child.unref();
currentProcess = uptermProcess;
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
output += chunk.toString();
};
uptermProcess.child.stdout?.on('data', appendChunk);
uptermProcess.child.stderr?.on('data', appendChunk);
return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
};
const stopAsync = async (): Promise<void> => {
await stopCurrentProcessAsync();
await fs.rm(stateDir, { recursive: true, force: true });
};
let connectionConfig: SshConnectionConfig;
try {
connectionConfig = await dialAsync();
} catch (err) {
await stopAsync();
throw err;
}
return {
get connectionConfig() {
return connectionConfig;
},
getConnectedClientCountAsync,
isAlive: () => currentProcess != null && isChildProcessAlive(currentProcess.child),
redialAsync: async () => {
connectionConfig = await dialAsync();
return connectionConfig;
},
stopAsync,
};
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.7.1",
"version": "23.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -26,7 +26,7 @@
},
"scripts": {
"prepack": "yarn run clean && yarn run build",
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
"lint": "oxlint --type-aware --max-warnings=0 --tsconfig ../../tsconfig.oxlint.json .",
"test": "echo 'No tests yet.'",
"watch": "yarn run build:dev -w",
"build:dev": "ncc build ./src/index.ts -o build/",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.7.1"
"@expo/steps": "23.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff. Show more