Compare commits

...
34 Commits
Author SHA1 Message Date
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
99 changed files with 5882 additions and 706 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
+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
+37 -2
View File
@@ -6,6 +6,41 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 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
### 🧹 Chores
## [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))
- [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))
### 🧹 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
@@ -15,11 +50,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 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
@@ -81,6 +115,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Soft-stop EAS Simulator sessions before the backing job times out so cleanup can finish. ([#4117](https://github.com/expo/eas-cli/pull/4117) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "21.8.0",
"STAGING": "22.2.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.0.0",
"version": "22.3.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",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.0.0",
"version": "22.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -46,7 +46,7 @@
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.0.0",
"@expo/steps": "22.1.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -54,6 +54,7 @@
"@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 +78,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",
+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,
+2 -2
View File
@@ -103,7 +103,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -245,7 +245,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
@@ -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,6 +47,7 @@ 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';
@@ -73,6 +76,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -94,6 +99,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -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,
});
});
});
@@ -51,6 +51,29 @@ 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;
}
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -58,11 +81,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' }),
@@ -88,11 +112,11 @@ describe('downloadBuild', () => {
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 +130,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 +349,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,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
@@ -179,7 +181,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 +191,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) => {
@@ -0,0 +1,111 @@
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('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('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',
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
expect.any(Object)
);
});
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,83 @@
import { type bunyan } from '@expo/logger';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { compressCacheAsync } from '../saveCache';
import { decompressCacheAsync } from '../restoreCache';
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));
});
});
@@ -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();
});
});
@@ -8,9 +8,11 @@ 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';
@@ -26,6 +28,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 +55,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 +81,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 +153,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));
@@ -194,3 +243,47 @@ export async function downloadBuildAsync({
return { artifactPath: matchingFiles[0] };
}
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,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -14,6 +15,7 @@ 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';
@@ -120,19 +122,64 @@ 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'))))
) {
const xcodeVersion = await getXcodeVersion({ 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 (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;
}
}
@@ -154,6 +201,21 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
});
}
async function getXcodeVersion({ env }: { env: BuildStepEnv }): 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 xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
if (!xcodeVersion) {
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
}
return xcodeVersion;
}
async function getMaestroVersion({
env,
backend,
@@ -211,10 +273,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 +323,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 +334,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,94 @@
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';
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,
}),
],
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');
await launchApplicationAsync({
applicationIdentifier,
activityName,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
},
});
}
export async function launchApplicationAsync({
applicationIdentifier,
activityName,
runtimePlatform,
env,
logger,
}: {
applicationIdentifier: string;
activityName?: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier], {
env,
logger,
});
return;
}
if (!activityName) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY',
'Launching an Android application requires activity_name.'
);
}
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('adb', ['shell', 'am', 'start', '-n', `${applicationIdentifier}/${activityName}`], {
env,
logger,
});
}
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;
}
@@ -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,8 +15,9 @@ 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';
@@ -35,6 +36,11 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -48,10 +54,20 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
@@ -92,13 +108,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
export async function restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
@@ -136,7 +152,7 @@ export async function restoreCcacheAsync({
})
);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Restoring cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -147,8 +163,8 @@ export async function restoreCcacheAsync({
robotAccessToken,
paths: [cachePath],
key: cacheKey,
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
platform,
keyPrefixes: [getCcacheKeyPrefix(target)],
platform: target.platform,
});
await decompressCacheAsync({
@@ -175,7 +191,7 @@ export async function restoreCcacheAsync({
expoApiServerURL,
robotAccessToken,
paths: [cachePath],
platform,
platform: target.platform,
});
await decompressCacheAsync({
archivePath,
@@ -258,13 +274,14 @@ export async function restoreGradleCacheAsync({
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const journalPath = path.join(gradleCachesPath, 'journal-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
@@ -15,7 +15,11 @@ import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
@@ -30,6 +34,11 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -43,10 +52,20 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
@@ -67,14 +86,14 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
export async function saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
evictUsedBefore: Date;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
@@ -99,7 +118,7 @@ export async function saveCcacheAsync({
}
try {
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Saving cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -143,7 +162,7 @@ export async function saveCcacheAsync({
key: cacheKey,
paths: [cachePath],
size,
platform,
platform: target.platform,
});
} catch (err) {
logger.error({ err }, 'Failed to save cache');
@@ -167,6 +186,7 @@ export async function saveGradleCacheAsync({
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const journalPath = path.join(gradleCachesPath, 'journal-1');
try {
await fs.promises.access(buildCachePath);
@@ -186,9 +206,11 @@ export async function saveGradleCacheAsync({
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
await fs.promises.mkdir(journalPath, { recursive: true });
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
@@ -204,7 +226,7 @@ export async function saveGradleCacheAsync({
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
paths: [buildCachePath, journalPath],
size,
platform: Platform.ANDROID,
});
@@ -348,7 +348,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}`);
@@ -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}).`
@@ -146,6 +152,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -0,0 +1,311 @@
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,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
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 serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
break;
case BuildRuntimePlatform.LINUX:
break;
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
},
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
await serveSim.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}` : ''}`
);
}
@@ -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;
@@ -221,6 +227,7 @@ export function createStartArgentRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -1,4 +1,9 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import {
@@ -21,9 +26,23 @@ export function createStartServeSimRemoteSessionBuildFunction(
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env, signal }) => {
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 }, { 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;
logger.info('Starting serve-sim remote session.');
@@ -34,6 +53,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
@@ -49,6 +69,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
});
} finally {
@@ -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,95 @@
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: '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;
}
@@ -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';
@@ -20,6 +24,7 @@ import {
} 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() }));
@@ -152,6 +157,20 @@ 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(metricsCorsOriginToServeSimArgs, () => {
@@ -216,14 +235,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,10 +251,10 @@ 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);
@@ -360,8 +379,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 +402,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 +463,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,216 @@
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 {
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: 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);
}
}
@@ -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');
});
});
});
@@ -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,7 +19,7 @@ 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_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
@@ -144,77 +145,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);
}
}
@@ -565,18 +596,24 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
return args;
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_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',
@@ -669,20 +706,24 @@ 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}.`);
logger.info(
`Launching ${createServeSimPackageSpec(packageVersion)} 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 }),
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
env,
});
@@ -691,7 +732,7 @@ export async function startServeSimWithTunnelAsync(
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'serve-sim',
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
@@ -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({
@@ -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,
})
);
@@ -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-');
});
});
@@ -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,
+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}`);
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "22.0.0",
"version": "22.2.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": "22.0.0"
"@expo/steps": "22.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+233 -205
View File
File diff suppressed because it is too large. Load diff
+675 -14
View File
@@ -10454,6 +10454,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isEligibleForObserveNotice",
"description": "Tells you if the project can show the Observe promotional notification. This field is part of\nthe observe-notification experiment.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": true,
"deprecationReason": "This field is deprecated from its first release. It supplies data for the observe-notification promotion on the project page. It will be removed when the promotion ends. Do not rely on it."
},
{
"name": "lastDeletionAttemptTime",
"description": null,
@@ -14829,6 +14845,39 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "overviewStability",
"description": "Crash-free rates with previous-period comparison for the Overview stability tiles.",
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AppObserveOverviewStabilityInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveOverviewStability",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "overviewUpdateComparison",
"description": "Per-update comparison within one app version: the embedded bundle plus each OTA update.",
@@ -16091,6 +16140,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "symbolicationBuild",
"description": "Build whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "Build",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timestamp",
"description": null,
@@ -17816,6 +17877,145 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppObserveErrorPlatformStats",
"description": "AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS.",
"fields": [
{
"name": "affectedSessions",
"description": "Distinct sessions on this platform that hit any exception.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "affectedUsers",
"description": "Distinct users on this platform that hit any exception.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "crashFreeSessions",
"description": "Fraction (0..1) of this platform's active sessions without a fatal error.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "crashFreeUsers",
"description": "Fraction (0..1) of this platform's active users without a fatal error.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "fatalCount",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nonFatalCount",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platform",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "totalErrors",
"description": "Total exception events in range on this platform.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "AppObserveErrorSeverity",
@@ -19706,7 +19906,7 @@
"fields": [
{
"name": "metrics",
"description": "One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS.",
"description": "One entry per (metric, platform) with data in range; one entry per exact device platform.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -19988,8 +20188,8 @@
"description": null,
"fields": [
{
"name": "affectedUsers",
"description": null,
"name": "crashCount",
"description": "Fatal exception events in range.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20035,8 +20235,163 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "crashedUsers",
"description": "Distinct users that hit a fatal error in range.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashCount",
"description": "Fatal exception events in the equal-length window before startTime.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashFreeSessions",
"description": "Same fractions for the equal-length window before startTime; null when it had no activity.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashFreeUsers",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Float",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashedUsers",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "series",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveOverviewStabilityBucket",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "totalErrors",
"description": "All exception events in range, for distinguishing quiet apps from crash-free ones.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppObserveOverviewStabilityBucket",
"description": null,
"fields": [
{
"name": "affectedUsers",
"description": "Approximate unique users that hit any exception in this bucket. Not summable across buckets.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "bucketStart",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "crashCount",
"description": null,
"args": [],
"type": {
@@ -20057,6 +20412,85 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AppObserveOverviewStabilityInput",
"description": "Stability-tile filters. No release filters: the tiles always span all versions.",
"fields": null,
"inputFields": [
{
"name": "bucketIntervalMinutes",
"description": "Series bucket size. Defaults to one day.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "endTime",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environment",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platform",
"description": null,
"type": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppObserveOverviewUpdate",
@@ -20208,15 +20642,23 @@
},
{
"name": "stability",
"description": null,
"description": "Error stats for this update, one entry per platform with activity; iOS includes iPadOS and tvOS.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveOverviewStability",
"ofType": null
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveErrorPlatformStats",
"ofType": null
}
}
}
},
"isDeprecated": false,
@@ -20543,7 +20985,7 @@
},
{
"name": "metrics",
"description": "One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS.",
"description": "One entry per (metric, platform) with data in range; each device OS (iOS, iPadOS, tvOS, macOS, Android) is a separate platform.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -20567,15 +21009,23 @@
},
{
"name": "stability",
"description": "Error stats for this version, all platforms combined.",
"description": "Error stats for this version, one entry per platform with activity; iOS includes iPadOS and tvOS.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveOverviewStability",
"ofType": null
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveErrorPlatformStats",
"ofType": null
}
}
}
},
"isDeprecated": false,
@@ -20926,6 +21376,24 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "IPADOS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MACOS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "TVOS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
@@ -24164,6 +24632,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "unavailableServiceUsageMetrics",
"description": "Services whose estimates could not be computed and are omitted from the other fields.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UnavailableServiceUsageMetric",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -25273,6 +25765,61 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppiumRunSessionRemoteConfig",
"description": null,
"fields": [
{
"name": "appiumUrl",
"description": "Appium server URL for the remote device.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "capabilities",
"description": "W3C capabilities for the device that backs this session.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "JSONObject",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewUrl",
"description": "URL of the web preview surface for the session. Null when web previews are\nnot available for the platform (e.g. Android).",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppleAppIdentifier",
@@ -36664,9 +37211,45 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "applicationArchiveUrl",
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildId",
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceIdentifier",
"description": "Identifier of the virtual device to start for the session. On iOS this is a\nSimulator device name or UDID (e.g. \"iPhone 16 Pro\"). On Android this is an\nAVD hardware profile id (e.g. \"pixel_7\"). If omitted, the runner picks a\ndefault device.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "maxIdleTimeMinutes",
"description": "Stop the session automatically after this many minutes without observed\nsession activity. Must be positive and smaller than the session's maximum\nduration (2 hours, or maxRunTimeMinutes when set). Only supported for\nagent-device and argent sessions. If omitted, the session has no idle\ntimeout.",
"description": "Stop the session automatically after this many minutes without observed\nsession activity. Must be positive and smaller than the session's maximum\nduration (2 hours, or maxRunTimeMinutes when set). Only supported for\nagent-device, argent, and Appium sessions. If omitted, the session has no idle\ntimeout.",
"type": {
"kind": "SCALAR",
"name": "Int",
@@ -36678,7 +37261,7 @@
},
{
"name": "maxRunTimeMinutes",
"description": "Override for the underlying turtle job run's max run time, in minutes. Must\nbe non-negative and smaller than 120 (2 hours). Only customizable on paid\nplans. If omitted, the default is derived based on the job run's priority.",
"description": "Maximum usable session duration, in minutes, starting when the remote session\nis ready. Must be non-negative and no greater than 40 for normal-priority\naccounts or 115 for high-priority accounts. The backing job receives an\nadditional five minutes for cleanup.",
"type": {
"kind": "SCALAR",
"name": "Int",
@@ -43068,6 +43651,11 @@
"name": "AgentDeviceRunSessionRemoteConfig",
"ofType": null
},
{
"kind": "OBJECT",
"name": "AppiumRunSessionRemoteConfig",
"ofType": null
},
{
"kind": "OBJECT",
"name": "ArgentRunSessionRemoteConfig",
@@ -43129,6 +43717,12 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "APPIUM",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ARGENT",
"description": null,
@@ -74486,6 +75080,49 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UnavailableServiceUsageMetric",
"description": null,
"fields": [
{
"name": "message",
"description": "User-displayable explanation of why the service's estimate is missing.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "service",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "EASService",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UniqueUsersOverTimeData",
@@ -79572,6 +80209,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "unavailableServiceUsageMetrics",
"description": "Services whose estimates could not be computed and are omitted from the other fields.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UnavailableServiceUsageMetric",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.0.0",
"version": "22.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.0.0",
"@expo/steps": "22.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -72,7 +72,7 @@
"@sentry/node": "7.77.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"agent-cli-detector": "0.1.2",
"agent-cli-detector": "0.1.6",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
@@ -1,17 +1,17 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import Go, { toRepackTargetSdkVersion } from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
@@ -81,6 +81,25 @@ describe('detectProjectSdkVersionAsync', () => {
});
});
describe('toRepackTargetSdkVersion', () => {
it.each([
['57', '57.0.0'],
['57.0.0', '57.0.0'],
['57.0.9', '57.0.0'],
['57.0.0-canary-20260101', '57.0.0'],
])('resolves %s to the %s repack target', (sdkVersion, expected) => {
expect(toRepackTargetSdkVersion(sdkVersion)).toBe(expected);
});
it('returns undefined when no version is requested', () => {
expect(toRepackTargetSdkVersion(undefined)).toBeUndefined();
});
it('returns the requested version unchanged when it holds no version number', () => {
expect(toRepackTargetSdkVersion('latest')).toBe('latest');
});
});
const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
@@ -112,7 +131,7 @@ describe('Go command', () => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []) {
function makeCmd(argv: string[] = []): Go {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
@@ -149,6 +168,17 @@ describe('Go command', () => {
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
it('requests the repack configuration with the repack target SDK version', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await makeCmd(['--sdk-version', '57']).run();
expect(WorkflowRunQuery.expoGoRepackConfigurationAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ sdkVersion: '57.0.0' })
);
});
it('prompts for SDK version when no project config is found', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
@@ -0,0 +1,37 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import fs from 'fs-extra';
import { computeFileMd5Base64Async } from '../uploads';
describe(computeFileMd5Base64Async, () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-uploads-test-'));
});
afterAll(async () => {
await fs.remove(tmpDir);
});
it('computes the base64 MD5 checksum of a small file', async () => {
const file = path.join(tmpDir, 'small.txt');
await fs.writeFile(file, 'abc');
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
expect(await computeFileMd5Base64Async(file)).toBe('kAFQmDzST7DWlj99KOF/cg==');
});
it('computes the same checksum as a one-shot hash for a file larger than one stream chunk', async () => {
const file = path.join(tmpDir, 'large.bin');
const data = Buffer.alloc(256 * 1024);
for (let i = 0; i < data.length; i++) {
data[i] = i % 251;
}
await fs.writeFile(file, data);
const expected = crypto.createHash('md5').update(data).digest('base64');
expect(await computeFileMd5Base64Async(file)).toBe(expected);
});
});
@@ -54,6 +54,18 @@ type FingerprintOrigin = {
update?: UpdateFragment;
};
type FingerprintInfo = {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
/**
* Whether the uploaded fingerprint source file could not be downloaded or parsed
* (e.g. it expired in GCS or is zero-length). The hash is still valid in that case,
* but `fingerprint.sources` is empty and a detailed diff cannot be computed.
*/
isFingerprintSourceUnavailable: boolean;
};
export default class FingerprintCompare extends EasCommand {
static override description = 'compare fingerprints of the current project, builds, and updates';
static override strict = false;
@@ -170,7 +182,15 @@ export default class FingerprintCompare extends EasCommand {
const { fingerprint: secondFingerprint, origin: secondFingerprintOrigin } =
secondFingerprintInfo;
const unavailableSourceHashes = [
firstFingerprintInfo.isFingerprintSourceUnavailable ? firstFingerprint.hash : null,
secondFingerprintInfo.isFingerprintSourceUnavailable ? secondFingerprint.hash : null,
].filter((hash): hash is string => hash !== null);
if (json) {
if (unavailableSourceHashes.length > 0) {
throw new Error(getUnavailableSourceMessage(unavailableSourceHashes));
}
printJsonOnlyOutput({ fingerprint1: firstFingerprint, fingerprint2: secondFingerprint });
return;
}
@@ -190,55 +210,62 @@ export default class FingerprintCompare extends EasCommand {
);
}
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
if (!fingerprintDiffs) {
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
return;
}
const filePathDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
}
return sourceType === 'dir' || sourceType === 'file';
});
if (filePathDiffs.length > 0) {
if (unavailableSourceHashes.length > 0) {
Log.newLine();
Log.log('📁 Paths with native dependencies:');
}
const fields = [];
for (const diff of filePathDiffs) {
const field = getDiffFilePathFields(diff);
if (!field) {
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
Log.warn(
`A detailed diff cannot be shown. ${getUnavailableSourceMessage(unavailableSourceHashes)}`
);
} else {
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
if (!fingerprintDiffs) {
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
return;
}
fields.push(field);
}
Log.log(
formatFields(fields, {
labelFormat: label => ` ${chalk.dim(label)}:`,
})
);
const contentDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
const filePathDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
}
return sourceType === 'dir' || sourceType === 'file';
});
if (filePathDiffs.length > 0) {
Log.newLine();
Log.log('📁 Paths with native dependencies:');
}
return sourceType === 'contents';
});
const fields = [];
for (const diff of filePathDiffs) {
const field = getDiffFilePathFields(diff);
if (!field) {
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
}
fields.push(field);
}
Log.log(
formatFields(fields, {
labelFormat: label => ` ${chalk.dim(label)}:`,
})
);
for (const diff of contentDiffs) {
printContentDiff(diff);
const contentDiffs = fingerprintDiffs.filter(diff => {
let sourceType;
if (diff.op === 'added') {
sourceType = diff.addedSource.type;
} else if (diff.op === 'removed') {
sourceType = diff.removedSource.type;
} else if (diff.op === 'changed') {
sourceType = diff.beforeSource.type;
}
return sourceType === 'contents';
});
for (const diff of contentDiffs) {
printContentDiff(diff);
}
}
if (nonInteractive) {
@@ -285,12 +312,8 @@ async function getFingerprintInfoAsync(
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
},
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
}
): Promise<{ fingerprint: Fingerprint; origin: FingerprintOrigin }> {
firstFingerprintInfo?: FingerprintInfo
): Promise<FingerprintInfo> {
if (hash) {
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
} else if (updateId) {
@@ -349,13 +372,9 @@ async function getFingerprintInfoInteractiveAsync({
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
firstFingerprintInfo?: FingerprintInfo;
environmentForProjectFingerprint?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
}): Promise<FingerprintInfo> {
const prompt = firstFingerprintInfo
? 'Select the second fingerprint to compare against'
: 'Select a reference fingerprint for comparison';
@@ -449,13 +468,9 @@ async function getFingerprintInfoFromLocalProjectAsync({
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
firstFingerprintInfo: FingerprintInfo;
environment?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
}): Promise<FingerprintInfo> {
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
throw new Error(
@@ -477,22 +492,27 @@ async function getFingerprintInfoFromLocalProjectAsync({
firstFingerprintPlatforms,
{ env }
);
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
return {
fingerprint,
origin: { type: FingerprintOriginType.Project },
isFingerprintSourceUnavailable: false,
};
}
async function getFingerprintFromUpdateFragmentAsync(
updateWithFingerprint: UpdateFragment
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
): Promise<FingerprintInfo> {
if (!updateWithFingerprint.fingerprint) {
throw new Error(`Fingerprint for update ${updateWithFingerprint.id} was not computed.`);
} else if (!updateWithFingerprint.fingerprint.debugInfoUrl) {
throw new Error(`Fingerprint source for update ${updateWithFingerprint.id} was not computed.`);
}
const { fingerprint, isFingerprintSourceUnavailable } =
await getFingerprintFromFingerprintFragmentAsync(updateWithFingerprint.fingerprint);
return {
fingerprint: await getFingerprintFromFingerprintFragmentAsync(
updateWithFingerprint.fingerprint
),
fingerprint,
isFingerprintSourceUnavailable,
platforms: [stringToAppPlatform(updateWithFingerprint.platform)],
origin: {
type: FingerprintOriginType.Update,
@@ -505,13 +525,14 @@ async function getFingerprintInfoFromHashAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
hash: string
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
): Promise<FingerprintInfo> {
const fingerprintFragment = await getFingerprintFragmentFromHashAsync(
graphqlClient,
projectId,
hash
);
const fingerprint = await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
const { fingerprint, isFingerprintSourceUnavailable } =
await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
let platforms;
const fingerprintBuilds = fingerprintFragment.builds?.edges.map(edge => edge.node) ?? [];
const fingerprintUpdates = fingerprintFragment.updates?.edges.map(edge => edge.node) ?? [];
@@ -522,6 +543,7 @@ async function getFingerprintInfoFromHashAsync(
}
return {
fingerprint,
isFingerprintSourceUnavailable,
platforms,
origin: {
type: FingerprintOriginType.Hash,
@@ -534,7 +556,7 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
projectId: string,
nonInteractive: boolean,
updateGroupIdOrUpdateId: string
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
): Promise<FingerprintInfo> {
// Some people may pass in update group id instead of update id, so add interactive support for that
try {
const maybeUpdateGroupId = updateGroupIdOrUpdateId;
@@ -578,15 +600,18 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
async function getFingerprintInfoFromBuildIdAsync(
graphqlClient: ExpoGraphqlClient,
buildId: string
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
): Promise<FingerprintInfo> {
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId);
if (!buildWithFingerprint.fingerprint) {
throw new Error(`Fingerprint for build ${buildId} was not computed.`);
} else if (!buildWithFingerprint.fingerprint.debugInfoUrl) {
throw new Error(`Fingerprint source for build ${buildId} was not computed.`);
}
const { fingerprint, isFingerprintSourceUnavailable } =
await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint);
return {
fingerprint: await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint),
fingerprint,
isFingerprintSourceUnavailable,
platforms: [buildWithFingerprint.platform],
origin: {
type: FingerprintOriginType.Build,
@@ -611,17 +636,48 @@ async function getFingerprintFragmentFromHashAsync(
return fingerprint;
}
async function fetchFingerprintSourceAsync(fingerprintDebugUrl: string): Promise<Fingerprint> {
const fingerprintResponse = await fetch(fingerprintDebugUrl);
if (!fingerprintResponse.ok) {
throw new Error(`Fingerprint source request failed: ${fingerprintResponse.status}`);
}
const fingerprintSource = (await fingerprintResponse.json()) as Partial<Fingerprint> | null;
if (!Array.isArray(fingerprintSource?.sources)) {
throw new Error('Fingerprint source file does not contain a list of sources');
}
return fingerprintSource as Fingerprint;
}
async function getFingerprintFromFingerprintFragmentAsync(
fingerprintFragment: Pick<FingerprintFragment, 'debugInfoUrl' | 'hash'>
): Promise<Fingerprint> {
): Promise<{ fingerprint: Fingerprint; isFingerprintSourceUnavailable: boolean }> {
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl;
if (!fingerprintDebugUrl) {
throw new Error(
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.`
);
}
const fingerprintResponse = await fetch(fingerprintDebugUrl);
return (await fingerprintResponse.json()) as Fingerprint;
try {
return {
fingerprint: await fetchFingerprintSourceAsync(fingerprintDebugUrl),
isFingerprintSourceUnavailable: false,
};
} catch (error) {
// Uploaded source files expire after a period of time or may be malformed
// (e.g. zero-length). The hash from the API is still valid, so fall back to a
// sourceless fingerprint instead of failing the whole comparison.
Log.debug(`Failed to fetch source for fingerprint hash ${fingerprintFragment.hash}`, error);
return {
fingerprint: { hash: fingerprintFragment.hash, sources: [] },
isFingerprintSourceUnavailable: true,
};
}
}
function getUnavailableSourceMessage(hashes: string[]): string {
return hashes.length === 1
? `The uploaded source file for fingerprint ${hashes[0]} has expired or is invalid.`
: `The uploaded source files for fingerprints ${hashes.join(' and ')} have expired or are invalid.`;
}
function printContentDiff(diff: FingerprintDiffItem): void {
+9 -16
View File
@@ -1,10 +1,11 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import semver from 'semver';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
@@ -29,7 +30,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync, selectAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
@@ -47,18 +48,9 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
export function toRepackTargetSdkVersion(sdkVersion: string | undefined): string | undefined {
const coerced = semver.coerce(sdkVersion);
return coerced ? `${coerced.major}.0.0` : sdkVersion;
}
async function setupTestFlightAsync(ascApp: App): Promise<void> {
@@ -120,7 +112,8 @@ export default class Go extends EasCommand {
default: 'My Expo Go',
}),
'sdk-version': Flags.string({
description: 'Expo Go SDK version to prepare (default: latest)',
description:
'Expo Go SDK version to prepare, for example 57 (default: the SDK version of the current project)',
required: false,
}),
credentials: Flags.boolean({
@@ -157,7 +150,7 @@ export default class Go extends EasCommand {
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
let sdkVersion = toRepackTargetSdkVersion(flags['sdk-version'] ?? detectedSdkVersion);
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
@@ -99,7 +99,7 @@ describe(IntegrationsSupabaseDashboard, () => {
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const { ora } = jest.requireMock('../../../../ora');
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
@@ -116,7 +116,7 @@ describe(IntegrationsSupabaseDashboard, () => {
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const { ora } = jest.requireMock('../../../../ora');
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
@@ -52,12 +52,15 @@ describe(SimulatorAvailability, () => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): SimulatorAvailability {
function createCommand(
argv: string[],
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
): SimulatorAvailability {
const command = new SimulatorAvailability(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
});
return command;
}
@@ -101,6 +104,19 @@ describe(SimulatorAvailability, () => {
});
});
it('emits JSON with available true for Expo admins when the account is gated', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
const command = createCommand(['--json'], { isExpoAdmin: true });
await command.runAsync();
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
available: true,
accountName: 'testuser',
});
});
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
@@ -23,6 +23,7 @@ import {
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../../simulator/expoGo';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -45,6 +46,7 @@ jest.mock('../../../simulator/env', () => ({
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/expoGo');
jest.mock('../../../prompts');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
@@ -78,6 +80,9 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockResolveExpoGoApplicationArchiveUrlAsync = jest.mocked(
resolveExpoGoApplicationArchiveUrlAsync
);
const mockOra = jest.mocked(ora);
const mockPromptAsync = jest.mocked(promptAsync);
@@ -161,6 +166,9 @@ describe(Simulator, () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(
'https://example.test/expo-go.tar.gz'
);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
@@ -252,15 +260,15 @@ describe(Simulator, () => {
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
);
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
mockByIdAsync.mock.invocationCallOrder[0]
@@ -282,6 +290,51 @@ describe(Simulator, () => {
);
});
it('writes the Appium environment and capabilities to the simulator dotenv', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
type: DeviceRunSessionType.Appium,
platform: AppPlatform.Ios,
remoteConfig: {
__typename: 'AppiumRunSessionRemoteConfig',
appiumUrl: 'https://appium.example.test',
capabilities: {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:udid': 'simulator-id',
},
webPreviewUrl: 'https://preview.example.test',
},
})
);
const { command } = createCommand([
'--platform',
'ios',
'--type',
'appium',
'--non-interactive',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ type: DeviceRunSessionType.Appium })
);
expect(fs.writeFile).toHaveBeenCalledWith(
simulatorDotenvPath,
expect.stringContaining(
`APPIUM_CAPS='{"platformName":"iOS","appium:automationName":"XCUITest","appium:udid":"simulator-id"}'`
)
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('eas simulator:exec <appium-client> [args...]')
);
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('https://appium.example.test')
);
});
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
const { command } = createCommand([
'--platform',
@@ -295,15 +348,15 @@ describe(Simulator, () => {
expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
);
expect(fs.writeFile).toHaveBeenNthCalledWith(
2,
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
);
});
@@ -366,7 +419,7 @@ describe(Simulator, () => {
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
});
it('forwards --name to the create mutation', async () => {
@@ -411,6 +464,145 @@ describe(Simulator, () => {
);
});
it('forwards --device to the create mutation as deviceIdentifier', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--device',
'iPhone 16 Pro',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
);
});
it('trims --device before sending it', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--device',
' iPhone 16 Pro ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
);
});
it('omits a blank --device', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ deviceIdentifier: undefined })
);
});
it('forwards --build-id to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
' 8d8b713c-1834-4bd3-91e6-46f895422cbc ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ buildId: '8d8b713c-1834-4bd3-91e6-46f895422cbc' })
);
});
it('forwards --application-archive-url to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--application-archive-url',
' https://example.test/builds/app.apk ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/builds/app.apk',
})
);
});
it.each([
['ios', AppPlatform.Ios, 'https://example.test/expo-go.tar.gz'],
['android', AppPlatform.Android, 'https://example.test/expo-go.apk'],
] as const)(
'resolves and forwards the %s Expo Go archive when --expo-go is passed',
async (platformFlag, appPlatform, applicationArchiveUrl) => {
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(applicationArchiveUrl);
const { command } = createCommand([
'--platform',
platformFlag,
'--non-interactive',
'--expo-go',
]);
await command.runAsync();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).toHaveBeenCalledWith({
platform: platformFlag,
projectDir,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl,
platform: appPlatform,
})
);
}
);
it('rejects passing --build-id and --application-archive-url together', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
'--application-archive-url',
'https://example.test/builds/app.tar.gz',
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it.each([
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--expo-go',
sourceFlag,
sourceValue,
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('stops the simulator session when interrupted before the session is ready', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
@@ -442,7 +634,7 @@ describe(Simulator, () => {
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]);
processExitSpy.mockRestore();
});
@@ -161,7 +161,7 @@ describe(SimulatorList, () => {
'--status',
'new',
'--type',
'argent',
'appium',
'--platform',
'ios',
'--name',
@@ -179,7 +179,7 @@ describe(SimulatorList, () => {
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Argent],
types: [DeviceRunSessionType.Appium],
platforms: [AppPlatform.Ios],
name: 'checkout',
},
@@ -10,6 +10,7 @@ import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorStop from '../stop';
@@ -18,6 +19,7 @@ jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
@@ -40,6 +42,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeStoppedDeviceRunSession(
@@ -70,6 +73,7 @@ describe(SimulatorStop, () => {
jest.clearAllMocks();
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
@@ -98,6 +102,7 @@ describe(SimulatorStop, () => {
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
@@ -120,6 +125,7 @@ describe(SimulatorStop, () => {
graphqlClient,
'session-from-env'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-from-env');
} finally {
if (previousDeviceRunSessionId === undefined) {
delete process.env[EAS_SIMULATOR_SESSION_ID];
@@ -37,7 +37,7 @@ export default class SimulatorAvailability extends EasCommand {
const {
projectId,
loggedIn: { graphqlClient },
loggedIn: { actor, graphqlClient },
} = await this.getContextAsync(SimulatorAvailability, {
nonInteractive,
});
@@ -50,6 +50,7 @@ export default class SimulatorAvailability extends EasCommand {
graphqlClient,
projectId
));
available ||= actor.isExpoAdmin;
fetchSpinner?.stop();
} catch (err) {
fetchSpinner?.fail('Failed to check EAS Simulator availability');
@@ -28,6 +28,7 @@ import {
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../simulator/expoGo';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
@@ -68,6 +69,24 @@ export default class Simulator extends EasCommand {
description:
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
}),
device: Flags.string({
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['application-archive-url', 'expo-go'],
}),
'application-archive-url': Flags.string({
description:
'Application archive URL to download, install, and launch before the simulator session is ready.',
exclusive: ['build-id', 'expo-go'],
}),
'expo-go': Flags.boolean({
description:
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
exclusive: ['build-id', 'application-archive-url'],
}),
type: Flags.option({
description: 'Type of simulator session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
@@ -134,6 +153,9 @@ export default class Simulator extends EasCommand {
// The server rejects blank names, so trim here and treat a whitespace-only
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
const deviceIdentifier = flags.device?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
await loadSimulatorEnvAsync(projectDir);
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
@@ -144,6 +166,12 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const applicationArchiveUrl = flags['expo-go']
? await resolveExpoGoApplicationArchiveUrlAsync({
platform: platform === AppPlatform.Ios ? 'ios' : 'android',
projectDir,
})
: applicationArchiveUrlFromFlag;
if (existingDeviceRunSessionId) {
Log.warn(
@@ -165,6 +193,9 @@ export default class Simulator extends EasCommand {
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
deviceIdentifier,
...(buildId ? { buildId } : {}),
...(applicationArchiveUrl ? { applicationArchiveUrl } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
@@ -400,7 +431,7 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Finished
) {
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
await resetSimulatorEnvVerboseAsync(projectDir);
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
return;
}
@@ -414,7 +445,7 @@ async function waitForSessionEndOrInterruptAsync({
);
if (stopped) {
spinner.succeed('Simulator session stopped');
await resetSimulatorEnvVerboseAsync(projectDir);
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
} else {
spinner.fail(
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -484,7 +515,7 @@ async function stopDeviceRunSessionAfterInterruptAsync({
);
if (stopped) {
spinner.succeed('Simulator session stopped');
await resetSimulatorEnvVerboseAsync(projectDir);
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
} else {
spinner.fail(
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -496,9 +527,12 @@ async function stopDeviceRunSessionAfterInterruptAsync({
process.exit(130);
}
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
async function resetSimulatorEnvVerboseAsync(
projectDir: string,
deviceRunSessionId: string
): Promise<void> {
try {
await resetSimulatorEnvAsync(projectDir);
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId);
} catch (err) {
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
throw err;
@@ -11,6 +11,7 @@ import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -68,6 +69,8 @@ export default class SimulatorStop extends EasCommand {
throw err;
}
await resetSimulatorEnvAsync(projectDir, flagId);
if (jsonFlag) {
printJsonOnlyOutput({ id: session.id, status: session.status });
}
@@ -260,7 +260,7 @@ describe(UpdateEmbeddedList, () => {
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('ago'))).toBe(true);
});
it('puts "All channels" first in the prompt, then channels in order', async () => {
@@ -24,7 +24,7 @@ import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildC
import { chooseDevicesAsync } from '../DeviceUtils';
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
@@ -6,8 +6,8 @@ import { promptAsync } from '../../../../prompts';
import { AppleTeamType } from '../authenticateTypes';
import * as Keychain from '../keychain';
import {
resolveAppleTeamTypeFromEnvironment,
resolveAppleTeamAsync,
resolveAppleTeamTypeFromEnvironment,
resolveAscApiKeyAsync,
resolveUserCredentialsAsync,
} from '../resolveCredentials';
+122 -12
View File
@@ -1472,6 +1472,12 @@ export type App = Project & {
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
/** iOS app credentials for the project */
iosAppCredentials: Array<IosAppCredentials>;
/**
* Tells you if the project can show the Observe promotional notification. This field is part of
* the observe-notification experiment.
* @deprecated This field is deprecated from its first release. It supplies data for the observe-notification promotion on the project page. It will be removed when the promotion ends. Do not rely on it.
*/
isEligibleForObserveNotice: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
/** Time of the last user activity (update, branch, submission). */
latestActivity: Scalars['DateTime']['output'];
@@ -2185,6 +2191,8 @@ export type AppObserve = {
navigationRoutes: AppObserveNavigationRoutesConnection;
/** Active users and sessions for the Overview engagement band, across all versions. */
overviewEngagement: AppObserveOverviewEngagement;
/** Crash-free rates with previous-period comparison for the Overview stability tiles. */
overviewStability: AppObserveOverviewStability;
/** Per-update comparison within one app version: the embedded bundle plus each OTA update. */
overviewUpdateComparison: AppObserveOverviewUpdateComparison;
/** Per-version, per-platform metric summaries for the Overview version-comparison matrix. */
@@ -2290,6 +2298,11 @@ export type AppObserveOverviewEngagementArgs = {
};
export type AppObserveOverviewStabilityArgs = {
input: AppObserveOverviewStabilityInput;
};
export type AppObserveOverviewUpdateComparisonArgs = {
input: AppObserveOverviewUpdateComparisonInput;
};
@@ -2439,6 +2452,8 @@ export type AppObserveCustomEvent = {
sessionId?: Maybe<Scalars['String']['output']>;
severityNumber?: Maybe<Scalars['Int']['output']>;
severityText?: Maybe<Scalars['String']['output']>;
/** Build whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported. */
symbolicationBuild?: Maybe<Build>;
timestamp: Scalars['DateTime']['output'];
};
@@ -2646,6 +2661,24 @@ export enum AppObserveErrorGroupsOrderBy {
MostUsers = 'MOST_USERS'
}
/** AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS. */
export type AppObserveErrorPlatformStats = {
__typename?: 'AppObserveErrorPlatformStats';
/** Distinct sessions on this platform that hit any exception. */
affectedSessions: Scalars['Int']['output'];
/** Distinct users on this platform that hit any exception. */
affectedUsers: Scalars['Int']['output'];
/** Fraction (0..1) of this platform's active sessions without a fatal error. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of this platform's active users without a fatal error. */
crashFreeUsers: Scalars['Float']['output'];
fatalCount: Scalars['Int']['output'];
nonFatalCount: Scalars['Int']['output'];
platform: AppObservePlatform;
/** Total exception events in range on this platform. */
totalErrors: Scalars['Int']['output'];
};
export enum AppObserveErrorSeverity {
Error = 'ERROR',
Fatal = 'FATAL'
@@ -2867,7 +2900,7 @@ export type AppObserveNavigationStat = {
export type AppObserveOverviewAllReleases = {
__typename?: 'AppObserveOverviewAllReleases';
/** One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS. */
/** One entry per (metric, platform) with data in range; one entry per exact device platform. */
metrics: Array<AppObserveOverviewVersionMetric>;
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2904,14 +2937,43 @@ export type AppObserveOverviewEngagementStat = {
export type AppObserveOverviewStability = {
__typename?: 'AppObserveOverviewStability';
affectedUsers: Scalars['Int']['output'];
/** Fatal exception events in range. */
crashCount: Scalars['Int']['output'];
/** Fraction (0..1) of active sessions without a fatal error. */
crashFreeSessions: Scalars['Float']['output'];
/** Fraction (0..1) of active users without a fatal error. */
crashFreeUsers: Scalars['Float']['output'];
/** Distinct users that hit a fatal error in range. */
crashedUsers: Scalars['Int']['output'];
/** Fatal exception events in the equal-length window before startTime. */
previousPeriodCrashCount: Scalars['Int']['output'];
/** Same fractions for the equal-length window before startTime; null when it had no activity. */
previousPeriodCrashFreeSessions?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashFreeUsers?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashedUsers: Scalars['Int']['output'];
series: Array<AppObserveOverviewStabilityBucket>;
/** All exception events in range, for distinguishing quiet apps from crash-free ones. */
totalErrors: Scalars['Int']['output'];
};
export type AppObserveOverviewStabilityBucket = {
__typename?: 'AppObserveOverviewStabilityBucket';
/** Approximate unique users that hit any exception in this bucket. Not summable across buckets. */
affectedUsers: Scalars['Int']['output'];
bucketStart: Scalars['DateTime']['output'];
crashCount: Scalars['Int']['output'];
};
/** Stability-tile filters. No release filters: the tiles always span all versions. */
export type AppObserveOverviewStabilityInput = {
/** Series bucket size. Defaults to one day. */
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveOverviewUpdate = {
__typename?: 'AppObserveOverviewUpdate';
/** Null for the embedded bundle. */
@@ -2929,7 +2991,8 @@ export type AppObserveOverviewUpdate = {
metrics: Array<AppObserveOverviewUpdateMetric>;
/** When the update was published; null for the embedded bundle. */
publishedAt?: Maybe<Scalars['DateTime']['output']>;
stability: AppObserveOverviewStability;
/** Error stats for this update, one entry per platform with activity; iOS includes iPadOS and tvOS. */
stability: Array<AppObserveErrorPlatformStats>;
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2967,10 +3030,10 @@ export type AppObserveOverviewVersion = {
__typename?: 'AppObserveOverviewVersion';
appVersion: Scalars['String']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/** One entry per (metric, platform) with data in range; iOS includes tvOS and iPadOS. */
/** One entry per (metric, platform) with data in range; each device OS (iOS, iPadOS, tvOS, macOS, Android) is a separate platform. */
metrics: Array<AppObserveOverviewVersionMetric>;
/** Error stats for this version, all platforms combined. */
stability: AppObserveOverviewStability;
/** Error stats for this version, one entry per platform with activity; iOS includes iPadOS and tvOS. */
stability: Array<AppObserveErrorPlatformStats>;
uniqueUserCount: Scalars['Int']['output'];
};
@@ -3011,7 +3074,10 @@ export type AppObserveOverviewVersionSummary = {
export enum AppObservePlatform {
Android = 'ANDROID',
Ios = 'IOS'
Ios = 'IOS',
Ipados = 'IPADOS',
Macos = 'MACOS',
Tvos = 'TVOS'
}
export enum AppObservePropertyType {
@@ -3438,6 +3504,8 @@ export type AppUsageMetricTotal = {
planMetrics: Array<EstimatedUsage>;
/** Total cost of overages, in cents */
totalCost: Scalars['Float']['output'];
/** Services whose estimates could not be computed and are omitted from the other fields. */
unavailableServiceUsageMetrics: Array<UnavailableServiceUsageMetric>;
};
export type AppUsageMetrics = {
@@ -3600,6 +3668,19 @@ export type AppWorkflowsInsightsWorkflowsArgs = {
timespan: WorkflowsInsightsTimespanInput;
};
export type AppiumRunSessionRemoteConfig = {
__typename?: 'AppiumRunSessionRemoteConfig';
/** Appium server URL for the remote device. */
appiumUrl: Scalars['String']['output'];
/** W3C capabilities for the device that backs this session. */
capabilities: Scalars['JSONObject']['output'];
/**
* URL of the web preview surface for the session. Null when web previews are
* not available for the platform (e.g. Android).
*/
webPreviewUrl?: Maybe<Scalars['String']['output']>;
};
export type AppleAppIdentifier = {
__typename?: 'AppleAppIdentifier';
account: Account;
@@ -5153,18 +5234,36 @@ export type CreateDeviceRunSessionEventLogUploadSessionResult = {
export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
* Application archive URL to download, install, and launch before the simulator session
* becomes available. Mutually exclusive with buildId.
*/
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
/**
* EAS Build to install and launch before the simulator session becomes available.
* Mutually exclusive with applicationArchiveUrl.
*/
buildId?: InputMaybe<Scalars['ID']['input']>;
/**
* Identifier of the virtual device to start for the session. On iOS this is a
* Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android this is an
* AVD hardware profile id (e.g. "pixel_7"). If omitted, the runner picks a
* default device.
*/
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Stop the session automatically after this many minutes without observed
* session activity. Must be positive and smaller than the session's maximum
* duration (2 hours, or maxRunTimeMinutes when set). Only supported for
* agent-device and argent sessions. If omitted, the session has no idle
* agent-device, argent, and Appium sessions. If omitted, the session has no idle
* timeout.
*/
maxIdleTimeMinutes?: InputMaybe<Scalars['Int']['input']>;
/**
* Override for the underlying turtle job run's max run time, in minutes. Must
* be non-negative and smaller than 120 (2 hours). Only customizable on paid
* plans. If omitted, the default is derived based on the job run's priority.
* Maximum usable session duration, in minutes, starting when the remote session
* is ready. Must be non-negative and no greater than 40 for normal-priority
* accounts or 115 for high-priority accounts. The backing job receives an
* additional five minutes for cleanup.
*/
maxRunTimeMinutes?: InputMaybe<Scalars['Int']['input']>;
/**
@@ -6054,7 +6153,7 @@ export type DeviceRunSessionQueryByIdArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export enum DeviceRunSessionStatus {
Errored = 'ERRORED',
@@ -6065,6 +6164,7 @@ export enum DeviceRunSessionStatus {
export enum DeviceRunSessionType {
AgentDevice = 'AGENT_DEVICE',
Appium = 'APPIUM',
Argent = 'ARGENT',
ServeSim = 'SERVE_SIM'
}
@@ -10540,6 +10640,13 @@ export enum TurtleSshTransportType {
UptermV1 = 'UPTERM_V1'
}
export type UnavailableServiceUsageMetric = {
__typename?: 'UnavailableServiceUsageMetric';
/** User-displayable explanation of why the service's estimate is missing. */
message: Scalars['String']['output'];
service: EasService;
};
export type UniqueUsersOverTimeData = {
__typename?: 'UniqueUsersOverTimeData';
data: LineChartData;
@@ -11218,6 +11325,8 @@ export type UsageMetricTotal = {
planMetrics: Array<EstimatedUsage>;
/** Total cost of overages, in cents */
totalCost: Scalars['Float']['output'];
/** Services whose estimates could not be computed and are omitted from the other fields. */
unavailableServiceUsageMetrics: Array<UnavailableServiceUsageMetric>;
};
export enum UsageMetricType {
@@ -14893,6 +15002,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null }
| { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string }
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
@@ -92,6 +92,11 @@ export const DeviceRunSessionQuery = {
toolsAuthToken
webPreviewUrl
}
... on AppiumRunSessionRemoteConfig {
appiumUrl
capabilities
webPreviewUrl
}
... on ServeSimRunSessionRemoteConfig {
previewUrl
}
@@ -74,10 +74,10 @@ export function mergeEnvContent(rawContent: string, newVars: Record<string, stri
continue;
}
// match[0] runs past the value onto a following comment line, so spans come from the groups.
const [keyStart, keyEnd] = match.indices[1]!;
const [keyStart, keyEnd] = match.indices[1];
const valueRange = match.indices[2];
const definitionEnd = valueRange
? valueRange[1] - (/\s+$/.exec(match[2]!)?.[0].length ?? 0)
? valueRange[1] - (/\s+$/.exec(match[2])?.[0].length ?? 0)
: keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0);
if (!rewritten.has(key)) {
@@ -61,7 +61,7 @@ const { AppStoreConnectApiKeyQuery } =
const mockApp = { id: '123', bundleId: 'com.example.app' };
function createMockCredentialsCtx(ensureAuthResult?: any) {
function createMockCredentialsCtx(ensureAuthResult?: any): any {
return {
appStore: {
ensureAuthenticatedAsync: jest.fn(() => ({
@@ -73,7 +73,7 @@ function createMockCredentialsCtx(ensureAuthResult?: any) {
} as any;
}
function createBaseArgs(overrides: Record<string, any> = {}) {
function createBaseArgs(overrides: Record<string, any> = {}): any {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
@@ -41,7 +41,7 @@ jest.mock('../../prompts', () => ({
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
function createArgs(overrides: Record<string, any> = {}): any {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as any,
@@ -37,7 +37,7 @@ const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
>;
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
function createArgs(overrides: Record<string, any> = {}): any {
return {
projectDir: '/app',
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
@@ -278,7 +278,7 @@ async function syncPreviewSetAsync(
await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
id: previewSet.id,
filePath: absolutePath,
waitForProcessing: true,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
@@ -234,7 +234,7 @@ async function syncScreenshotSetAsync(
const newScreenshot = await logAsync(
() =>
AppScreenshot.uploadAsync(localization.context, {
id: screenshotSet!.id,
id: screenshotSet.id,
filePath: absolutePath,
waitForProcessing: true,
}),
@@ -10,7 +10,7 @@ export function startAndEndTime({
daysBack?: number;
start?: string;
end?: string;
}) {
}): { startTime: string; endTime: string } {
let startTime: string;
let endTime: string;
@@ -0,0 +1,17 @@
import { getConfigFilePaths } from '@expo/config';
import { getPrivateExpoConfigAsync } from './expoConfig';
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
@@ -1,4 +1,5 @@
import * as fs from 'fs-extra';
import { parse as parseDotenv } from 'dotenv';
import {
EAS_SIMULATOR_SESSION_ID,
@@ -16,12 +17,15 @@ describe(resetSimulatorEnvAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest
.mocked(fs.readFile)
.mockResolvedValue(`${EAS_SIMULATOR_SESSION_ID}='session-123'\n` as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(fs.truncate).mockResolvedValue(undefined as never);
});
it('overwrites the simulator dotenv file with the header only', async () => {
await resetSimulatorEnvAsync(projectDir);
await resetSimulatorEnvAsync(projectDir, 'session-123');
expect(fs.writeFile).toHaveBeenCalledWith(simulatorDotenvPath, SIMULATOR_DOTENV_FILE_HEADER, {
flag: 'r+',
@@ -34,18 +38,28 @@ describe(resetSimulatorEnvAsync, () => {
it('ignores a missing simulator dotenv file', async () => {
const err = Object.assign(new Error('missing file'), { code: 'ENOENT' });
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
jest.mocked(fs.readFile).mockRejectedValue(err as never);
await expect(resetSimulatorEnvAsync(projectDir)).resolves.toBeUndefined();
await expect(resetSimulatorEnvAsync(projectDir, 'session-123')).resolves.toBeUndefined();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.truncate).not.toHaveBeenCalled();
});
it('does not overwrite a simulator dotenv file for a different session', async () => {
await resetSimulatorEnvAsync(projectDir, 'different-session');
expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.truncate).not.toHaveBeenCalled();
});
it('rethrows non-missing-file errors', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
jest.mocked(fs.readFile).mockRejectedValue(err as never);
await expect(resetSimulatorEnvAsync(projectDir)).rejects.toThrow('permission denied');
await expect(resetSimulatorEnvAsync(projectDir, 'session-123')).rejects.toThrow(
'permission denied'
);
});
});
@@ -68,9 +82,21 @@ describe(writeSimulatorEnvAsync, () => {
expect(fs.writeFile).toHaveBeenCalledWith(
simulatorDotenvPath,
SIMULATOR_DOTENV_FILE_HEADER +
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
);
});
it('preserves serialized Appium capabilities as one dotenv value', async () => {
const capabilities = JSON.stringify({
platformName: 'iOS',
note: `It's important to preserve "quotes" and \\slashes`,
});
await writeSimulatorEnvAsync(projectDir, { APPIUM_CAPS: capabilities });
const writtenContent = jest.mocked(fs.writeFile).mock.calls[0][1];
expect(parseDotenv(String(writtenContent)).APPIUM_CAPS).toBe(capabilities);
});
});
@@ -0,0 +1,102 @@
import spawnAsync from '@expo/spawn-async';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../expoGo';
jest.mock('@expo/spawn-async');
jest.mock('../../project/detectProjectSdkVersionAsync');
const projectDir = '/test/project';
const mockDetectProjectSdkVersionAsync = jest.mocked(detectProjectSdkVersionAsync);
const mockSpawnAsync = jest.mocked(spawnAsync);
describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockDetectProjectSdkVersionAsync.mockResolvedValue('55.0.0');
mockSpawnAsync.mockResolvedValue({
stdout: 'https://example.test/expo-go.tar.gz\n',
} as never);
});
it.each(['ios', 'android'] as const)(
'resolves the Expo Go URL for SDK 55 on %s',
async platform => {
await expect(resolveExpoGoApplicationArchiveUrlAsync({ platform, projectDir })).resolves.toBe(
'https://example.test/expo-go.tar.gz'
);
expect(mockSpawnAsync).toHaveBeenCalledWith(
'npx',
['--yes', 'expo-go', 'url', platform, '55'],
{
cwd: projectDir,
stdio: 'pipe',
}
);
}
);
it.each([
[
'plain output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from https://example.test/Expo-Go-57.0.9.tar.gz\n',
],
[
'Markdown hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from [https://example.test/Expo-Go-57.0.9.tar.gz](https://example.test/Expo-Go-57.0.9.tar.gz)\n',
],
[
'terminal hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from \u001B]8;;https://example.test/Expo-Go-57.0.9.tar.gz\u0007' +
'https://example.test/Expo-Go-57.0.9.tar.gz\u001B]8;;\u0007\n',
],
])('extracts the URL from %s', async (_description, stdout) => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).resolves.toBe('https://example.test/Expo-Go-57.0.9.tar.gz');
});
it.each([undefined, 'UNVERSIONED'])(
'rejects an undetectable SDK version (%s)',
async sdkVersion => {
mockDetectProjectSdkVersionAsync.mockResolvedValue(sdkVersion);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected."
);
expect(mockSpawnAsync).not.toHaveBeenCalled();
}
);
it('reports how to diagnose an expo-go failure', async () => {
mockSpawnAsync.mockRejectedValue(new Error('network unavailable') as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'android', projectDir })
).rejects.toThrow(
'Failed to resolve the Expo Go download URL for SDK 55 on android. network unavailable ' +
'Run "npx expo-go url android 55" to diagnose the problem'
);
});
it.each([
'',
'not a URL',
'file:///tmp/expo-go.app',
'Resolving the correct Expo Go version...\nDownload Expo Go from nowhere\n',
])('rejects an invalid expo-go URL (%s)', async stdout => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow('expo-go returned an invalid download URL for SDK 55 on ios.');
});
});
@@ -0,0 +1,39 @@
import { DeviceRunSessionType } from '../../graphql/generated';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
formatRemoteSessionInstructions,
getRemoteSessionEnvironmentVariables,
} from '../utils';
const iosAppiumConfig = {
__typename: 'AppiumRunSessionRemoteConfig' as const,
appiumUrl: 'https://appium.example.test',
capabilities: {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:udid': 'simulator-id',
},
webPreviewUrl: 'https://preview.example.test',
};
describe('Appium simulator configuration', () => {
it('maps the appium CLI value to the GraphQL enum', () => {
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE.appium).toBe(DeviceRunSessionType.Appium);
});
it('creates the Appium client environment', () => {
expect(getRemoteSessionEnvironmentVariables(iosAppiumConfig)).toEqual({
APPIUM_URL: 'https://appium.example.test',
APPIUM_CAPS:
'{"platformName":"iOS","appium:automationName":"XCUITest","appium:udid":"simulator-id"}',
});
});
it('does not print the URL in managed dotenv instructions', () => {
const instructions = formatRemoteSessionInstructions(iosAppiumConfig, 'dotenv');
expect(instructions).toContain('eas simulator:exec <appium-client> [args...]');
expect(instructions).toContain('https://preview.example.test');
expect(instructions).not.toContain('https://appium.example.test');
});
});
+20 -2
View File
@@ -1,7 +1,10 @@
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
import { parse as parseDotenv } from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import Log from '../log';
export const SIMULATOR_DOTENV_FILE_NAME = '.env.eas-simulator';
export const EAS_SIMULATOR_SESSION_ID = 'EAS_SIMULATOR_SESSION_ID';
export const SIMULATOR_DOTENV_FILE_HEADER =
@@ -26,17 +29,32 @@ export async function writeSimulatorEnvAsync(
const simulatorDotenvContent =
SIMULATOR_DOTENV_FILE_HEADER +
Object.entries(environmentVariables)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.map(([key, value]) => `${key}='${value}'`)
.join('\n') +
'\n';
await fs.writeFile(simulatorDotenvFilePath, simulatorDotenvContent);
}
export async function resetSimulatorEnvAsync(projectDir: string): Promise<void> {
export async function resetSimulatorEnvAsync(
projectDir: string,
expectedDeviceRunSessionId: string
): Promise<void> {
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
try {
const currentEnv = parseDotenv(await fs.readFile(simulatorDotenvFilePath, 'utf8'));
if (currentEnv[EAS_SIMULATOR_SESSION_ID] !== expectedDeviceRunSessionId) {
// The file was overwritten by a newer simulator session, so it is no longer
// ours to reset. This is expected when sessions overlap; log at debug level
// for troubleshooting without surfacing noise during normal use.
Log.debug(
`Skipping ${SIMULATOR_DOTENV_FILE_NAME} reset: it belongs to simulator session ${
currentEnv[EAS_SIMULATOR_SESSION_ID] ?? '(unknown)'
}, not ${expectedDeviceRunSessionId}.`
);
return;
}
await fs.writeFile(simulatorDotenvFilePath, SIMULATOR_DOTENV_FILE_HEADER, { flag: 'r+' });
await fs.truncate(simulatorDotenvFilePath, Buffer.byteLength(SIMULATOR_DOTENV_FILE_HEADER));
} catch (err) {
+71
View File
@@ -0,0 +1,71 @@
import { stripVTControlCharacters } from 'util';
import spawnAsync from '@expo/spawn-async';
import semver from 'semver';
import { EasCommandError } from '../commandUtils/errors';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
export type ExpoGoPlatform = 'android' | 'ios';
const EXPO_GO_DOWNLOAD_MESSAGE = 'Download Expo Go from';
function extractApplicationArchiveUrl(stdout: string): string | undefined {
const output = stripVTControlCharacters(stdout);
const outputLines = output.trim().split(/\r?\n/);
const downloadMessageLine = outputLines.find(line => line.includes(EXPO_GO_DOWNLOAD_MESSAGE));
const bareUrlOutput = outputLines.length === 1 ? outputLines[0] : undefined;
const lineWithUrl = downloadMessageLine ?? bareUrlOutput;
return lineWithUrl?.match(/https?:\/\/[^\s\])]+/)?.[0];
}
export async function resolveExpoGoApplicationArchiveUrlAsync({
platform,
projectDir,
}: {
platform: ExpoGoPlatform;
projectDir: string;
}): Promise<string> {
const sdkVersion = await detectProjectSdkVersionAsync(projectDir);
const sdkMajorVersion = semver.coerce(sdkVersion)?.major;
if (!sdkMajorVersion) {
throw new EasCommandError(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected. " +
'Make sure the project has Expo installed and a valid app config, or use --build-id or --application-archive-url instead.'
);
}
const args = ['--yes', 'expo-go', 'url', platform, String(sdkMajorVersion)];
let stdout: string;
try {
({ stdout } = await spawnAsync('npx', args, {
cwd: projectDir,
stdio: 'pipe',
}));
} catch (error) {
const reason = error instanceof Error ? ` ${error.message}` : '';
throw new EasCommandError(
`Failed to resolve the Expo Go download URL for SDK ${sdkMajorVersion} on ${platform}.${reason} ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
const applicationArchiveUrl = extractApplicationArchiveUrl(stdout);
try {
if (!applicationArchiveUrl) {
throw new Error('Missing URL');
}
const parsedUrl = new URL(applicationArchiveUrl);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Unsupported protocol');
}
} catch {
throw new EasCommandError(
`expo-go returned an invalid download URL for SDK ${sdkMajorVersion} on ${platform}. ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
return applicationArchiveUrl;
}
+29
View File
@@ -22,6 +22,7 @@ export function formatSimulatorUnavailableMessage(accountName: string): string {
// so adding a new enum value in codegen fails the build until it is wired up here.
export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.Appium]: 'appium',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
@@ -50,6 +51,11 @@ export function getRemoteSessionEnvironmentVariables(
ARGENT_TOOLS_URL: remoteConfig.toolsUrl,
...(remoteConfig.toolsAuthToken ? { ARGENT_AUTH_TOKEN: remoteConfig.toolsAuthToken } : {}),
};
case 'AppiumRunSessionRemoteConfig':
return {
APPIUM_URL: remoteConfig.appiumUrl,
APPIUM_CAPS: JSON.stringify(remoteConfig.capabilities),
};
case 'ServeSimRunSessionRemoteConfig':
return {};
}
@@ -126,6 +132,29 @@ export function formatRemoteSessionInstructions(
}
return lines.join('\n');
}
case 'AppiumRunSessionRemoteConfig': {
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
const lines =
configType === 'dotenv'
? [
'Run an Appium client with the simulator session environment:',
'',
'eas simulator:exec <appium-client> [args...]',
]
: [
'Run the following in your shell to attach an Appium client to this session:',
'',
...Object.entries(environmentVariables).map(
([key, value]) => `export ${key}='${value}'`
),
'',
'<appium-client> [args...]',
];
if (remoteConfig.webPreviewUrl) {
lines.push('', 'Open the iOS simulator preview:', '', remoteConfig.webPreviewUrl);
}
return lines.join('\n');
}
case 'ServeSimRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
+1 -1
View File
@@ -1,4 +1,4 @@
import { App, Platform as ApplePlatform, AppStoreVersion, Build } from '@expo/apple-utils';
import { App, AppStoreVersion, Platform as ApplePlatform, Build } from '@expo/apple-utils';
import chalk from 'chalk';
import { getSubmissionDetailsUrl } from './utils/urls';
+28
View File
@@ -1,3 +1,5 @@
import crypto from 'node:crypto';
import FormData from 'form-data';
import fs from 'fs-extra';
import { Response } from 'node-fetch';
@@ -148,6 +150,13 @@ async function uploadWithSignedUrlWithProgressAsync(
): Promise<Response> {
const fileStat = await fs.stat(file);
const fileSize = fileStat.size;
if (fileSize === 0) {
throw new Error(
`Failed to upload ${file}: the file is empty. It may not have been written correctly. Retry the command, and report this issue if it persists.`
);
}
const contentMd5 = await computeFileMd5Base64Async(file);
const readStream = fs.createReadStream(file);
const uploadPromise = fetch(signedUrl.url, {
@@ -155,6 +164,8 @@ async function uploadWithSignedUrlWithProgressAsync(
body: readStream,
headers: {
...signedUrl.headers,
'Content-Length': String(fileSize), // make GCS reject the request if the file size changes during the upload
'Content-MD5': contentMd5, // make GCS reject the request if the uploaded bytes do not match this checksum
},
});
@@ -171,6 +182,11 @@ async function uploadWithSignedUrlWithProgressAsync(
});
try {
const response = await uploadPromise;
if (currentSize !== fileSize) {
throw new Error(
`Failed to upload ${file}: sent ${currentSize} of ${fileSize} bytes. The file may have changed during the upload. Retry the command, and report this issue if it persists.`
);
}
handleProgressEvent({ isComplete: true });
return response;
} catch (error: any) {
@@ -178,3 +194,15 @@ async function uploadWithSignedUrlWithProgressAsync(
throw error;
}
}
/**
* Computes the base64-encoded MD5 checksum of a file, the format GCS expects
* in the `Content-MD5` request header.
*/
export async function computeFileMd5Base64Async(file: string): Promise<string> {
const hash = crypto.createHash('md5');
for await (const chunk of fs.createReadStream(file)) {
hash.update(chunk as Buffer);
}
return hash.digest('base64');
}
@@ -36,7 +36,9 @@ describe(ensurePNGIsNotTransparentAsync, () => {
fs.createReadStream(transparentPngPath).pipe(response);
});
await new Promise<void>((res, rej) => {
const onError = (error: Error): void => rej(error);
const onError = (error: Error): void => {
rej(error);
};
server.once('error', onError);
server.listen(0, '127.0.0.1', () => {
server.off('error', onError);
@@ -163,7 +163,7 @@ describe(pollForBackgroundJobReceiptAsync, () => {
});
it('tolerates consecutive CombinedError fetches before NULL_RECEIPT', async () => {
const { CombinedError } = jest.requireActual('@urql/core') as typeof import('@urql/core');
const { CombinedError } = jest.requireActual('@urql/core');
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const receiptId = '123';
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "22.0.0",
"version": "22.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "22.0.0",
"@expo/build-tools": "22.3.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "22.0.0",
"version": "22.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -83,6 +83,10 @@ export class BuildStepOutputAccessor {
return Object.values(this.outputById);
}
public get wasExecuted(): boolean {
return this.executed;
}
public getOutputValueByName(name: string): string | undefined {
if (!this.executed) {
throw new BuildStepRuntimeError(
+14 -1
View File
@@ -59,7 +59,7 @@ export class CompositeBuildStep extends BuildStep {
displayName,
outputs,
fn,
// `scope` already applies the call-site `if`, so `always()` disables the default skip-on-failure.
// Call-site `if` lives on `scope`. `always()` survives a child failure; `shouldExecuteStep` still skips a call that never ran so outputs stay unset.
ifCondition: '${{ always() }}',
compositeFunctionScope: scope,
});
@@ -87,6 +87,19 @@ export class CompositeBuildStep extends BuildStep {
}
}
public override shouldExecuteStep(options: { runByDefault: boolean }): boolean {
if (!super.shouldExecuteStep(options)) {
return false;
}
return options.runByDefault || this.anyAuthoredChildExecuted();
}
private anyAuthoredChildExecuted(): boolean {
return this.children.some(child =>
child instanceof CompositeBuildStep ? child.anyAuthoredChildExecuted() : child.wasExecuted
);
}
public getFlattenedSteps(): BuildStep[] {
const flattened = this.children.flatMap(child =>
child instanceof CompositeBuildStep ? child.getFlattenedSteps() : [child]
@@ -835,6 +835,80 @@ describe('BuildWorkflow hook execution', () => {
expect(statuses['setup']).toBe(BuildStepStatus.SKIPPED);
});
it('skips the outputs node of an always() composite entry whose children an earlier entry suppressed', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{ uses: 'test/first-boom', id: 'first-boom' },
{ uses: './.eas/functions/setup', id: 'setup', if: '${{ always() }}' },
],
},
externalFunctions: [
anchorFunction(),
recordingFunction('first-boom', { failWith: new Error('first failed') }),
recordingFunction('plain-child'),
versionFunction('read-version', '1.2.3'),
],
compositeFunctionCatalog: {
'./.eas/functions/setup': {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [
{ id: 'plain', uses: 'test/plain-child' },
{ id: 'read', uses: 'test/read-version' },
],
},
},
},
});
await expect(workflow.executeAsync()).rejects.toThrow('first failed');
expect(executionLog).toEqual(['first-boom']);
const statuses = hookStepStatuses(workflow);
expect(statuses['setup__plain']).toBe(BuildStepStatus.SKIPPED);
expect(statuses['setup__read']).toBe(BuildStepStatus.SKIPPED);
expect(statuses['setup']).toBe(BuildStepStatus.SKIPPED);
expect(metrics).toEqual([
{ anchor: 'install_node_modules', timing: 'before', result: 'failed' },
]);
});
it('skips the outputs node of an after composite entry whose first child has an unevaluable if', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
after_install_node_modules: [{ uses: './.eas/functions/cleanup', id: 'cleanup' }],
},
externalFunctions: [
anchorFunction(),
recordingFunction('broken-gate'),
recordingFunction('plain-child'),
],
compositeFunctionCatalog: {
'./.eas/functions/cleanup': {
outputs: { note: { value: 'done' } },
runs: {
steps: [
{
id: 'broken',
uses: 'test/broken-gate',
if: '${{ nonexistent.object.property }}',
},
{ id: 'plain', uses: 'test/plain-child' },
],
},
},
},
});
await expect(workflow.executeAsync()).rejects.toThrow();
expect(executionLog).toEqual(['anchor']);
const statuses = hookStepStatuses(workflow);
expect(statuses['cleanup__broken']).toBe(BuildStepStatus.SKIPPED);
expect(statuses['cleanup__plain']).toBe(BuildStepStatus.SKIPPED);
expect(statuses['cleanup']).toBe(BuildStepStatus.SKIPPED);
expect(metrics).toEqual([]);
});
it('a later hook step consumes the composite call outputs via steps.<call-id>', async () => {
const captured: unknown[] = [];
const workflow = await parseAsync({
@@ -319,6 +319,172 @@ describe('StepsConfigParser local composite functions', () => {
expect(outputsStep.getOutputValueByName('version_expr')).toBe('');
});
it('skips the outputs step when an earlier failure suppressed every inner step', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: {
steps: [{ id: 'read', uses: 'test/set-version' }],
},
},
},
steps: [
{ id: 'boom', uses: 'test/fail' },
{ uses: SETUP, id: 'setup', if: '${{ always() }}' },
{
id: 'after',
uses: 'test/passthrough',
if: '${{ always() }}',
with: { value: '${{ steps.setup.outputs.version }}' },
},
],
externalFunctions: [setVersionFunction(), failingFunction(), passThroughFunction()],
});
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
const innerStep = workflow.buildSteps.find(s => s.id === 'setup__read');
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
const afterStep = workflow.buildSteps.find(s => s.id === 'after');
expect(innerStep?.status).toBe(BuildStepStatus.SKIPPED);
expect(outputsStep?.status).toBe(BuildStepStatus.SKIPPED);
expect(afterStep?.getOutputValueByName('out')).toBe('');
expect(() => outputsStep?.getOutputValueByName('version')).toThrow(
'has not been executed yet'
);
});
it('runs the outputs step when an inner step opted past the earlier failure with always()', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
[SETUP]: {
outputs: {
version: { value: '${{ steps.read.outputs.version }}' },
skipped: { value: '${{ steps.later.outputs.out }}' },
},
runs: {
steps: [
{ id: 'read', uses: 'test/set-version', if: '${{ always() }}' },
{ id: 'later', uses: 'test/passthrough', with: { value: 'x' } },
],
},
},
},
steps: [
{ id: 'boom', uses: 'test/fail' },
{ uses: SETUP, id: 'setup', if: '${{ always() }}' },
],
externalFunctions: [setVersionFunction(), failingFunction(), passThroughFunction()],
});
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
expect(workflow.buildSteps.find(s => s.id === 'setup__read')?.status).toBe(
BuildStepStatus.SUCCESS
);
expect(workflow.buildSteps.find(s => s.id === 'setup__later')?.status).toBe(
BuildStepStatus.SKIPPED
);
expect(outputsStep?.status).toBe(BuildStepStatus.SUCCESS);
expect(outputsStep?.getOutputValueByName('version')).toBe('$(echo injected)');
expect(outputsStep?.getOutputValueByName('skipped')).toBe('');
});
it('skips the outer outputs step when a nested call without outputs was fully suppressed', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/outer': {
outputs: { version: { value: '${{ steps.mid.outputs.version }}' } },
runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] },
},
'./.eas/functions/inner': {
runs: { steps: [{ id: 'read', uses: 'test/set-version' }] },
},
},
steps: [
{ id: 'boom', uses: 'test/fail' },
{ uses: './.eas/functions/outer', id: 'top', if: '${{ always() }}' },
],
externalFunctions: [setVersionFunction(), failingFunction()],
});
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
expect(workflow.buildSteps.find(s => s.id === 'top__mid__read')?.status).toBe(
BuildStepStatus.SKIPPED
);
expect(workflow.buildSteps.find(s => s.id === 'top')?.status).toBe(BuildStepStatus.SKIPPED);
});
it('runs the outer outputs step when a grandchild of a nested call executed', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/outer': {
outputs: { version: { value: '${{ steps.mid.outputs.inner_version }}' } },
runs: {
steps: [{ uses: './.eas/functions/inner', id: 'mid', if: '${{ always() }}' }],
},
},
'./.eas/functions/inner': {
outputs: { inner_version: { value: '${{ steps.read.outputs.version }}' } },
runs: { steps: [{ id: 'read', uses: 'test/set-version', if: '${{ always() }}' }] },
},
},
steps: [
{ id: 'boom', uses: 'test/fail' },
{ uses: './.eas/functions/outer', id: 'top', if: '${{ always() }}' },
],
externalFunctions: [setVersionFunction(), failingFunction()],
});
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
expect(workflow.buildSteps.find(s => s.id === 'top__mid__read')?.status).toBe(
BuildStepStatus.SUCCESS
);
expect(workflow.buildSteps.find(s => s.id === 'top')?.getOutputValueByName('version')).toBe(
'$(echo injected)'
);
});
it('runs the outer outputs step when a grandchild of a nested call without outputs executed via always()', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
'./.eas/functions/outer': {
outputs: { version: { value: '${{ steps.mid.outputs.version }}' } },
runs: {
steps: [{ uses: './.eas/functions/inner', id: 'mid', if: '${{ always() }}' }],
},
},
'./.eas/functions/inner': {
runs: { steps: [{ id: 'read', uses: 'test/set-version', if: '${{ always() }}' }] },
},
},
steps: [
{ id: 'boom', uses: 'test/fail' },
{ uses: './.eas/functions/outer', id: 'top', if: '${{ always() }}' },
],
externalFunctions: [setVersionFunction(), failingFunction()],
});
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
expect(error.message).toBe('inner failed');
expect(workflow.buildSteps.find(s => s.id === 'top__mid__read')?.status).toBe(
BuildStepStatus.SUCCESS
);
expect(workflow.buildSteps.find(s => s.id === 'top__mid')).toBeUndefined();
const outputsStep = workflow.buildSteps.find(s => s.id === 'top');
expect(outputsStep?.status).toBe(BuildStepStatus.SUCCESS);
expect(outputsStep?.getOutputValueByName('version')).toBe('');
});
it('resolves an out-of-scope action output reference to an empty string', async () => {
const workflow = await parseCompositeFunctions({
catalog: {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "22.0.0",
"version": "22.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "22.0.0",
"@expo/build-tools": "22.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "22.0.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.0.0",
"@expo/steps": "22.1.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@hapi/boom": "10.0.1",
Generated
+74 -20
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:22.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:22.3.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2303,15 +2303,16 @@ __metadata:
"@expo/logger": "npm:22.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.6.1"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.0.0"
"@expo/steps": "npm:22.1.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
"@types/content-disposition": "npm:0.5.9"
"@types/fs-extra": "npm:^11.0.4"
"@types/jest": "npm:^29.5.12"
"@types/lodash": "npm:^4.17.4"
@@ -2326,6 +2327,7 @@ __metadata:
"@urql/core": "npm:^6.0.1"
bplist-parser: "npm:0.3.2"
bunyan: "npm:^1.8.15"
content-disposition: "npm:1.0.1"
fast-glob: "npm:^3.3.2"
fast-xml-parser: "npm:^4.4.1"
fs-extra: "npm:^11.2.0"
@@ -2818,15 +2820,16 @@ __metadata:
languageName: node
linkType: hard
"@expo/repack-app@npm:~0.6.1":
version: 0.6.1
resolution: "@expo/repack-app@npm:0.6.1"
"@expo/repack-app@npm:~0.9.0":
version: 0.9.0
resolution: "@expo/repack-app@npm:0.9.0"
dependencies:
commander: "npm:^14.0.2"
picocolors: "npm:^1.1.1"
zsign-wasm: "npm:^0.0.2"
bin:
repack-app: bin/cli.js
checksum: 10c0/fdf3088b4f327393056631270bf90a67ba949e7994892a62abc985bd9478119ab2f1d5be35fa1efb6bd1d2780c49a3dbb87cb669d9c65b44e3d9a0b41fbf091f
checksum: 10c0/0a493d97b80b98a1a90172baf81d7efee6b65399bf15171aae9c6505be3eedd4e59d05a8a33b3a6b6b720a2a96b7c2c5043c8ec92e390a5f917e2dd139d88aab
languageName: node
linkType: hard
@@ -2884,7 +2887,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:22.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:22.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
@@ -2959,7 +2962,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:22.0.0"
"@expo/build-tools": "npm:22.3.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:22.0.0"
@@ -2968,7 +2971,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.0.0"
"@expo/steps": "npm:22.1.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -7084,7 +7087,7 @@ __metadata:
languageName: node
linkType: hard
"@types/content-disposition@npm:*":
"@types/content-disposition@npm:*, @types/content-disposition@npm:0.5.9":
version: 0.5.9
resolution: "@types/content-disposition@npm:0.5.9"
checksum: 10c0/9fd520dae1a9c7b85909e59f548f905a670a6e2f83642f9734d1b05126f64ac8c8e2c282a663edf27d58bc34fbbacf46a4d4a95d3802b42106174a19a68175c8
@@ -8024,12 +8027,12 @@ __metadata:
languageName: node
linkType: hard
"agent-cli-detector@npm:0.1.2":
version: 0.1.2
resolution: "agent-cli-detector@npm:0.1.2"
"agent-cli-detector@npm:0.1.6":
version: 0.1.6
resolution: "agent-cli-detector@npm:0.1.6"
bin:
agent-cli-detector: dist/cli.js
checksum: 10c0/d7751eab293c4543f079988ffe519951859c254edb204fd3105ff49c114ac466d8ef05ba00b3b574fdd5038f9c4e24414d977918f5bccfe9a147628f83d013c3
checksum: 10c0/0653c78987a7382ebd6e19334a2c9362eae700486614354fcf4d10b173202997b7b330545391deab343fe830930ea1b1db90c0d2a5106e6e9a3254abeef6ed6d
languageName: node
linkType: hard
@@ -9492,7 +9495,7 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:~1.0.1":
"content-disposition@npm:1.0.1, content-disposition@npm:~1.0.1":
version: 1.0.1
resolution: "content-disposition@npm:1.0.1"
checksum: 10c0/bd7ff1fe8d2542d3a2b9a29428cc3591f6ac27bb5595bba2c69664408a68f9538b14cbd92479796ea835b317a09a527c8c7209c4200381dedb0c34d3b658849e
@@ -9724,7 +9727,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:22.0.0"
"@expo/steps": "npm:22.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10297,7 +10300,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:22.0.0"
"@expo/build-tools": "npm:22.3.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -10371,7 +10374,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.0.0"
"@expo/steps": "npm:22.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -10404,7 +10407,7 @@ __metadata:
"@types/wrap-ansi": "npm:3.0.0"
"@urql/core": "npm:4.0.11"
"@urql/exchange-retry": "npm:1.2.0"
agent-cli-detector: "npm:0.1.2"
agent-cli-detector: "npm:0.1.6"
ajv: "npm:8.11.0"
ajv-formats: "npm:2.1.1"
axios: "npm:1.18.1"
@@ -12327,6 +12330,13 @@ __metadata:
languageName: node
linkType: hard
"immediate@npm:~3.0.5":
version: 3.0.6
resolution: "immediate@npm:3.0.6"
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
languageName: node
linkType: hard
"immutable@npm:^5.1.5":
version: 5.1.9
resolution: "immutable@npm:5.1.9"
@@ -13769,6 +13779,18 @@ __metadata:
languageName: node
linkType: hard
"jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
lie: "npm:~3.3.0"
pako: "npm:~1.0.2"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:^1.0.5"
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
languageName: node
linkType: hard
"just-diff-apply@npm:^5.2.0":
version: 5.5.0
resolution: "just-diff-apply@npm:5.5.0"
@@ -14011,6 +14033,15 @@ __metadata:
languageName: node
linkType: hard
"lie@npm:~3.3.0":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"lilconfig@npm:^3.1.3":
version: 3.1.3
resolution: "lilconfig@npm:3.1.3"
@@ -16196,6 +16227,13 @@ __metadata:
languageName: node
linkType: hard
"pako@npm:~1.0.2":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
languageName: node
linkType: hard
"param-case@npm:^3.0.4":
version: 3.0.4
resolution: "param-case@npm:3.0.4"
@@ -17456,6 +17494,13 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
@@ -19992,3 +20037,12 @@ __metadata:
checksum: 10c0/5a2db7e59177a3d7e202543f5136cb87b97b047b77c8a3d824098d3fa8b80d3aa40a0a5f296965c3b82dfdccdd05dbbfacce91347f16a39c675680fd7b1ab109
languageName: node
linkType: hard
"zsign-wasm@npm:^0.0.2":
version: 0.0.2
resolution: "zsign-wasm@npm:0.0.2"
dependencies:
jszip: "npm:^3.10.1"
checksum: 10c0/f8919b66031c93970987fa9ac0e742cc7975e4c4c6fbf60fa477e1154109a50a4bf907b75dff8afe72b457df49ce5d060e3a3b93ba3c92b8c453d85206058f22
languageName: node
linkType: hard