Compare commits

..
240 Commits
Author SHA1 Message Date
Expo CI 33225d2b73 v20.1.0 2026-06-05 21:36:09 +00:00
Gabe Debes 7a97f297ee [eas-cli] Add eas update:embedded:delete command (#3809) 2026-06-05 14:27:30 -07:00
Gabe Debes 5f952fd33b [eas-cli] Add eas update:embedded:list command (#3811)
* [eas-cli] Add eas update:embedded:list command

* [eas-cli] Put "All channels" first in the channel-filter prompt

* [eas-cli] Refresh graphql schema snapshot
2026-06-05 14:09:31 -07:00
Gabe Debes 45354d12da [eas-cli] Add eas update:embedded:view command (#3810)
* [eas-cli] Add eas update:embedded:view command

* [eas-cli] Unit tests for EmbeddedUpdateQuery wrapper
2026-06-05 10:04:14 -07:00
Ash Wu 598f0050be [build-tools] Route Datadog metrics and logs by build vs job-run target (#3819)
Signed-off-by: Ash Wu <[email protected]>
2026-06-05 14:01:20 +08:00
Gabe Debes 543261b68a [build-tools] disable apsd in iOS simulator (#3795)
* [build-tools] Disable apsd in iOS Simulator after boot

* [build-tools] Add unit test for disableApsdAsync

* [build-tools] Use { err } logging convention for apsd warning

* [build-tools] Test apsd disable in start_ios_simulator
2026-06-02 09:08:43 -07:00
Krystof Woldrich e1148f2b00 feat(simulator): add exec command and config persistence via dotenv (#3777) 2026-06-02 14:31:27 +00:00
Gabe Debes 2c1837f8ba [build-tools] Auto-upload embedded bundle after build when opt-in flag is set (#3767)
* [build-tools] Auto-upload embedded bundle after build when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set

* [build-tools] Add changelog entry for #3767

* Move isEASUpdateConfigured check inside uploadEmbeddedBundleAsync

* Skip simulator builds; use endsWith for Android archive entry matching

* Skip simulator builds; add tests for uploadEmbeddedBundleAsync

* Simplify archive pattern branch for readability

* Add tests for upload error paths and builder env gate

* Address review: explicit platform branch, channel check first, safe zip.close

* Move #3767 entry back under ## main after v20 release rebase
2026-05-29 10:44:06 -07:00
Szymon Dziedzic 7bdc009c5e [eas-cli] use webrtc for simulator service streaming (#3807)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Use webrtc for better perf

# How

Use webrtc with serve sim for better perf

# Test Plan

Looks good locally
2026-05-29 18:28:56 +02:00
Expo CI b5592e16ad update CHANGELOG.md 2026-05-29 00:04:43 +00:00
Expo CI 890387b3b6 v20.0.0 2026-05-29 00:01:30 +00:00
Douglas Lowder b93c550d12 [eas-cli] Unhide observe commands (#3793)
# Why

Unhide the commands when ready.

# How

Remove `hidden = true` lines, clean up outdated warnings.

# Test Plan

CI should pass.
2026-05-28 16:55:18 -07:00
Mustafa Abban e648961eca [worker] Fail silently on cache archive errors in CacheManager (#3802) 2026-05-28 16:55:36 -04:00
Gabe Debes 15bbb3150d [eas-cli] Add eas update:embedded:upload command (#3720)
* [eas-cli] Add eas update:embedded:upload command

* Add tests for json output flag and mutation wrappers

* Apply prettier formatting to mutation test

* Invoke upload progress callback in test to cover no-op

* Use fs-extra so manifest read and bundle existence check can be awaited

* Drop misleading already-registered warn from upload

* Recognize EMBEDDED_UPDATE_ALREADY_EXISTS on upload and warn

* Drop already-exists CLI warn, server message is clear on its own

* Surface EMBEDDED_UPDATE_ALREADY_EXISTS as a single clean error on upload

* Flatten retry catch with early continue
2026-05-28 09:35:23 -07:00
Szymon Dziedzic 45c4cf3c32 [build-tools] Use h264 codec for serve-sim stream (#3800)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Try if h264 codec will improve stream perf for serve-sim

# How

Add `--codec h264` to the single `serve-sim` invocation in `startServeSimWithTunnelAsync` ([packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts](https://github.com/expo/eas-cli/blob/main/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts)). All three callers — `start_agent_device_remote_session`, `start_argent_remote_session`, and `start_serve_sim_remote_session` — route through this helper, so the flag applies everywhere `serve-sim` is launched.

# Test Plan

- `yarn typecheck` on `build-tools` — only the two pre-existing `refreshAdHocProvisioningProfile` errors on `main`, unrelated to this change.
- Manual verification will happen once the step runs end-to-end on a turtle worker; the change is a single CLI flag added to an existing argument list.
2026-05-28 15:13:35 +02:00
Stanisław Chmiela 0ab2a04b67 [build-tools] Support absolute artifact glob paths (#3788)
See https://app.datadoghq.com/logs?query=findArtifacts&agg_m=count&agg_m_source=base&agg_q=status%2Cservice&agg_q_source=base%2Cbase&agg_t=count&clustering_pattern_field_path=message&cols=host%2Cservice&messageDisplay=inline&refresh_mode=sliding&sort_m=%2C&sort_m_source=%2C&sort_t=%2C&storage=hot&stream_sort=desc&top_n=10%2C10&top_o=top%2Ctop&viz=pattern&x_missing=true%2Ctrue&from_ts=1779570516711&to_ts=1779743316711&live=true

---

## Summary
- applies the validated absolute-glob artifact lookup behavior directly
- keeps existing exact absolute path handling intact
- adds unit coverage for absolute glob artifact patterns

## Why
The dry-run Sentry instrumentation validated that absolute artifact glob support is the intended behavior. This PR removes the dry-run path and uses the supported lookup logic directly.

## Validation
- yarn oxfmt packages/build-tools/src/utils/artifacts.ts packages/build-tools/src/utils/__tests__/artifacts.test.ts
- yarn --cwd packages/build-tools test --runTestsByPath src/utils/__tests__/artifacts.test.ts
2026-05-28 14:32:31 +02:00
Stanisław Chmiela 544739a613 [build-tools] Respect EXPO precompiled opt-out for iOS pods (#3799)
### Motivation
- Ensure `EXPO_USE_PRECOMPILED_MODULES=0` in the build environment always wins and is not overwritten by `EAS_USE_PRECOMPILED_MODULES=1` to avoid surprising behavior for customers disabling precompiled modules. 
- Make the precedence explicit and observable via logs so maintainers can reason about the opt-out path.

### Description
- Added an early guard in `resolvePrecompiledModulesPodInstallEnvAsync` to return early when `ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0'` and log the opt-out message. (`packages/build-tools/src/ios/pod.ts`).
- Logged the presence of `EXPO_USE_PRECOMPILED_MODULES=0` with a clear message to explain why precompiled env vars are not injected. (`packages/build-tools/src/ios/pod.ts`).
- Extended the test helper to allow passing `EXPO_USE_PRECOMPILED_MODULES` via the build context environment and added a regression test that verifies `EXPO_USE_PRECOMPILED_MODULES=0` is preserved and that no base URL is set when both flags are present. (`packages/build-tools/src/ios/__tests__/pod.test.ts`).

### Testing
- Ran formatter: `npx oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts` which completed successfully. 
- Ran the unit test file: `cd packages/build-tools && yarn test src/ios/__tests__/pod.test.ts` which failed in this environment due to missing workspace module resolution for `@expo/eas-build-job` and `@expo/turtle-spawn` (test runner error, not a behavior regression).

------
[Codex Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a180a1026848328b76bf4240a5872b1)
2026-05-28 11:50:29 +02:00
Szymon DziedzicandClaude Opus 4.7 86a891b377 [build-tools] Use ngrok tunnels for remote device run sessions (#3796)
* [build-tools] Use ngrok tunnels for remote device run sessions

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

* [build-tools] Use in-process @ngrok/ngrok SDK for our tunnels

Replace the spawned ngrok CLI (binary install + stdout URL parsing +
web_addr workaround) with the in-process @ngrok/ngrok SDK for the
agent-device and argent tunnels. The ngrok CLI is still installed for
serve-sim, which shells out to it for --tunnel-provider ngrok.

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

* [build-tools] Drop ngrok CLI install; serve-sim uses the SDK

serve-sim now embeds the @ngrok/ngrok SDK for --tunnel-provider ngrok, so
the ngrok agent no longer needs to be on PATH. No tunnel binary is
installed anywhere now: our own tunnels and serve-sim both run the agent
in-process. Removes ensureNgrokCliInstalledAsync and its callers.

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

* [build-tools] Address review feedback

Simplify the "env var not set" SystemError messages (drop "created by the
API server" / the domain example), reword the fail-fast comments, and make
matchLabeledUrl take an options object.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 15:18:36 +02:00
Gabe Debes d51ca46f9c [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4 (#3794)
* [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4

* [build-tools] Add changelog entry

* [build-tools] Use os.tmpdir() and node:fs for readiness screenshot

* [build-tools] Add unit test for Simulator readiness screenshot
2026-05-27 00:23:47 -07:00
Gabe DebesandStanisław Chmiela cfc2c76ae4 [eas-cli] eas go: prompt for SDK version interactively (#3768)
* [eas-cli] eas go: use SDK version from current project when available

* [eas-cli] eas go: prompt for SDK version interactively

* Regenerate GQL types for ExpoGoBuild supportedSdkVersions

* Update packages/eas-cli/src/commands/go.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Address review feedback on selectSdkVersionAsync

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-05-26 15:20:27 -07:00
Kadi Kraman 489f38a6e5 [eas-cli] improve naming for observe commands (#3778)
* Rename /metrics -> /metrics-summary

* Rename events -> metrics

* Rename logs -> events

* Add changelog

* Add update download to metric names

* Fix warnings

* Add changelog PR number

* Format

* Rename metric-summary -> metrics-summary
2026-05-26 11:23:20 +01:00
Ash Wu 5f26518feb [build-tools] handle custom Gymfile err in xcactivitylog (#3790)
Signed-off-by: Ash Wu <[email protected]>
2026-05-26 14:09:32 +08:00
Stanisław Chmiela 2fc5f203b6 [build-tools] Log artifact dry-run results to Datadog (#3791)
## Summary
- adds `Datadog.log` for posting build-scoped log events to Datadog
- moves the Gradle cache restore log emission onto the shared Datadog helper
- replaces artifact dry-run Sentry reporting with Datadog logs for match, mismatch, and error outcomes

## Why
The absolute artifact glob validation should show both positive and negative dry-run outcomes. Sentry only surfaced mismatches/errors, while Datadog logs let us count successful matches as well.

## Validation

CI should pass. Going to review Datadog logs post-merge.
2026-05-25 18:01:39 +02:00
Expo CI 4a18363061 update CHANGELOG.md 2026-05-25 13:46:03 +00:00
Expo CI 27a2e2abc7 v19.1.0 2026-05-25 13:43:21 +00:00
Szymon Świerk 149f5d86a4 [build-tools] Pass refresh ad-hoc provisioning profile through build:internal (#3770)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Git-based integration builds call `eas build:internal` via `runEasBuildInternalAsync`. The refresh flag must reach the CLI and remain on the job object returned to the rest of the build.

# How

Forward `--refresh-ad-hoc-provisioning-profile` when set on the iOS job; retain `refresh_ad_hoc_provisioning_profile` in `validateEasBuildInternalResult`.

# Test Plan

`cd packages/build-tools && yarn test src/common/__tests__/easBuildInternal.test.ts`
2026-05-25 12:18:13 +02:00
Szymon Świerk 18c2e82385 [eas-cli] Wire refresh ad-hoc profile through build:internal and prepareJob (#3769)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Workflow builds run `eas build:internal` on EAS workers to resolve credentials and emit the job JSON. The base branch adds `--refresh-ad-hoc-provisioning-profile` to eas build, but that flag was not available on `build:internal`, and the iOS job did not persist `refresh_ad_hoc_provisioning_profile` when the flag was set.

Without this wiring, git-based/workflow builds could not trigger ad-hoc profile refresh or pass the intent through to the job payload.

# How

- Add `--refresh-ad-hoc-provisioning-profile` to `eas build:internal` and pass it into `runBuildAndSubmitAsync` (same behavior as eas build).
- In `prepareJob.ts`, set `refresh_ad_hoc_provisioning_profile: true` on the iOS job when the credentials context has the flag enabled.

# Test Plan

`yarn typecheck`
2026-05-25 11:49:51 +02:00
Szymon Świerk 43ffe46428 [eas-build-job] Add refreshAdHocProvisioningProfile to iOS job (#3717)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

ENG-21094 needs a job field to carry ad-hoc provisioning profile refresh intent for git-based/workflow builds; #3716 added CLI behavior on eas build; this PR is schema-only.

# How

Updated `packages/eas-build-job/src/ios.ts` interface + Joi schema.

# Test Plan

`cd packages/eas-build-job && yarn test src/__tests__/ios.test.ts`
`yarn typecheck`
2026-05-25 11:43:14 +02:00
Ash Wu fb9b49cd5b [build-tools] keep exit code & stderr when xclogparser failed (#3772)
* [build-tools] keep exit code & stderr when xclogparser failed

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-25 15:15:21 +08:00
Stanisław Chmiela 17eb5f0687 [worker] Remove build phase stats websocket reporting (#3787)
We now send build phases through `Datadog`!

---

## Summary
- remove the stale BuildPhaseStats type and BuildContext reporting callback
- drop the worker BUILD_PHASE_STATS websocket message and service forwarding path
- update tests to assert the remaining Datadog phase duration metrics path

## Why
Build phase metrics now flow from worker to www to Datadog, so the old worker to launcher to Datadog reporting path was dead code.

## Validation
- yarn run -T oxfmt --check packages/eas-build-job/src/common.ts packages/eas-build-job/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/builders/__tests__/android.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/external/turtle.ts packages/worker/src/__integration__/utils.ts packages/worker/src/__unit__/service.test.ts
- yarn jest --config jest/unit-config.ts src/builders/__tests__/android.test.ts src/__tests__/context.test.ts --runInBand --no-cache (from packages/build-tools)
- yarn jest --config jest.config.unit.ts src/__unit__/service.test.ts --runInBand (from packages/worker)
- yarn typecheck
2026-05-25 08:36:40 +02:00
Stanisław Chmiela 6297ac19d9 [build-tools] Dry-run add support for absolute artifact glob paths (#3738)
## Summary

Updates artifact path resolution so absolute paths containing glob metacharacters are treated as glob patterns instead of literal paths.

This lets `eas/upload_artifact` handle paths like `/var/folders/.../T/maestro_xctestrunner_xcodebuild_output*`, while preserving the existing literal-path behavior for non-dynamic absolute paths.

## Why

`upload_artifact.path` already accepts paths or patterns, but absolute patterns were previously short-circuited through `fs.pathExists()` and never reached `fast-glob`.

## Test Plan

- `yarn workspace @expo/build-tools jest-unit packages/build-tools/src/utils/__tests__/artifacts.test.ts`

Note: the focused test was run in the existing local checkout with the same two-file patch. The clean PR worktree did not have its own install state.
2026-05-22 18:13:54 +02:00
Stanisław Chmiela 6d86f91dab [worker] Derive worker runtime info from os (#3783)
## Summary
- Remove worker-side `resourceClass` parsing and types from runtime config.
- Remove `ResourceClassToPlatform`, since worker env setup now uses the job platform directly.
- Replace resource-class VM spec logging with OS-derived CPU model, vCPU count, and total RAM.

## Notes
- CPU model is normalized to `Apple M4 Pro` (from "Apple M4 Pro (Virtual)") for Apple chips and `Intel`/`AMD` for cloud CPU models.

## Validation
- `yarn oxfmt --check packages/worker/src/config.ts packages/worker/src/displayRuntimeInfo.ts packages/worker/src/external/turtle.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
- verified `os.cpus().length` is 4 for 4 CPU tart VM and `os.totalmem() / 1024 / 1024 / 1024` is `8` for 8 GB RAM tart VM
2026-05-22 16:35:44 +02:00
Stanisław Chmiela cedfabe18a [worker] Size Gradle heap from runner memory (#3782)
## Summary
- Replace resource-class-based Gradle heap sizing with a live calculation from runner total memory.
- Preserve existing Gradle settings for metaspace, parallel execution, daemon disablement, and old-Java MaxPermSize handling.
- Add worker unit coverage for the effective heap sizes.

## Effective heap sizing
- 15-16 GB runners resolve to `-Xmx4g`.
- 31-32 GB runners resolve to `-Xmx8g`.
- Lower-memory runners are clamped to at least `-Xmx1g`.

## Validation
- `yarn oxfmt --check packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-05-22 16:18:36 +02:00
Stanisław Chmiela 20a21908d0 [worker] Stop logging dependency metrics failures to build output (#3784)
## Summary

- Stop reporting project dependency metrics failures to the build logger.
- Keep the existing Sentry warning capture for this failure path.

## Context

This addresses user-visible logging seen in build https://expo.dev/accounts/ammar_tripiq/projects/tripiq-client/builds/8f01c353-187b-4368-b569-94484d04220b where missing package.json caused:

```
Failed to report project dependencies metrics
ENOENT: no such file or directory, open '/Users/expo/workingdir/build/package.json'
```

## Validation

- yarn oxfmt packages/worker/src/external/analytics.ts
2026-05-22 16:09:55 +02:00
Stanisław Chmiela 18b1dd8d9d [build-tools] Preserve app entitlements when resigning iOS builds (#3785)
Not sure if this is the right fix, but sounds right?

## Summary

Updates the iOS build resign Fastfile to pass `use_app_entitlements: true` to Fastlane's `resign` action.

This makes EAS preserve and merge the app's existing code-signing entitlements when re-signing an IPA with a new provisioning profile, instead of signing directly with the broader provisioning-profile entitlements. This matters for capabilities like associated domains, where the profile may contain `*` but the app signature needs concrete values such as `applinks:...` for universal links to keep working.

Context: https://exponent-internal.slack.com/archives/C09H6RFAX27/p1779446992844929

## Validation

- `yarn install`
- `yarn workspace @expo/template-file build`
- `yarn workspace @expo/build-tools jest-unit fastfile.test.ts`
- `yarn oxfmt packages/build-tools/src/templates/FastfileResign.ts packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap`
2026-05-22 14:23:58 +02:00
Szymon Świerk fce2a77909 [eas-cli] Add --refresh-ad-hoc-provisioning-profile for non-interactive builds (#3716)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

When a new device is added to an account, and a non-interactive development build is run (e.g. invoked by CI or by a workflow job), even without the `freeze-credentials` flag, the provisioning profile is not refreshed, and the internal build is not available for the newly registered device. See the Linear issue: https://linear.app/expo/issue/ENG-7041/allow-including-all-eas-registered-devices-automatically-in-non

# How

Added an opt-in ad-hoc provisioning profile refresh to non-interactive builds with flag `--refresh-ad-hoc-provisioning-profile`.

When the flag is present, we hit `www` GQL API to get the ASC API key (the submission key), and we invoke the ad-hoc profile sync with all devices selected. `packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts` is where this logic happens.

The flag mustn't be present when `--freeze-credentials` is present, these are conflicting flags.

I'm leaning towards an opt-in flag instead of extending the default behavior because of the extra credentials configuration requirements and side effects of the provisioning profile refresh logic.

# Test Plan

Added/amended tests.

Manual verification:
1. Did `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential`, verified the provisioning profile matches the set of Apple devices on my account.
2. Changed the set of Apple devices on my account.
3. `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential` again, verified the re-generated provisioning profile matches the Apple devices on my account.
2026-05-22 10:41:42 +02:00
Expo CI c562f08da9 update CHANGELOG.md 2026-05-21 23:19:58 +00:00
Expo CI 5dee409567 v19.0.8 2026-05-21 23:17:53 +00:00
Gabe Debes ea2cd4f318 [eas-cli] eas go: use SDK version from current project when available (#3776)
* [eas-cli] eas go: use SDK version from current project when available

* fix fmt
2026-05-21 15:36:59 -07:00
Expo CI 639a7cce61 update CHANGELOG.md 2026-05-21 21:02:47 +00:00
Expo CI b0db6d1dfa v19.0.7 2026-05-21 21:00:27 +00:00
Szymon Dziedzic 783033acb9 [eas-cli] Add simulator:list command (#3775) 2026-05-21 22:58:13 +02:00
Expo CI 4366f517c1 update CHANGELOG.md 2026-05-21 17:08:37 +00:00
Expo CI 1e9a0981a5 v19.0.6 2026-05-21 17:05:52 +00:00
Szymon Dziedzic 43953d6f16 [eas-cli] Add --json flag to simulator:stop command (#3773)
# Why

`eas simulator:stop` only emitted human-readable spinner output, so
wrapper scripts and automation can't consume the resulting session id
and status. Sibling commands (e.g. `simulator:start`, `branch:rename`)
already expose `--json` for machine-readable output.

# How

- Swap `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags`
  (adds `--json`, preserves `--non-interactive`).
- Use `resolveNonInteractiveAndJsonFlags` so `--json` implies
  `--non-interactive`, matching `simulator:start`.
- Call `enableJsonOutput()` before any logging so spinner output is
  redirected to stderr when `--json` is set.
- On successful stop, emit `{ id, status }` via `printJsonOnlyOutput`.

# Test Plan

- `yarn typecheck`, `yarn lint`, and `yarn fmt:check` pass for the
  modified file.
- Manual: `eas simulator:stop --id <id> --json` writes only the JSON
  payload to stdout; spinner/log messages are redirected to stderr.
2026-05-21 18:03:46 +02:00
Krystof Woldrich 7bb8c0e749 feat(simulator): add get --json output (#3765) 2026-05-21 17:51:45 +02:00
Christian Falch 150176debc fix(precompile) changed min sdk-version for precompile to 5.0.26 (#3771)
* fix(precompile) changed min sdk-version for precompile to 5.0.26

After publishing new fixes also on SDK-55 for the precompile, we'd like to lock in on a higher version.

This commit fixes this by updating the tests and implementation to require 55.0.26 as the minimum sdk version.

* fixed changelog
2026-05-21 15:38:22 +02:00
Szymon Dziedzic fa6c877a81 [build-tools] Add start_argent_remote_session build function (#3757) 2026-05-20 20:16:26 +02:00
Expo CI 9ee8758aac update CHANGELOG.md 2026-05-20 18:14:13 +00:00
Expo CI d084e9ab9d v19.0.5 2026-05-20 18:11:50 +00:00
Stanisław Chmiela ec0ac26fc3 [eas-cli] Improve iOS config fallback Sentry diagnostics (#3763)
## Summary
- capture an explicit `iOS entitlements config fallback` Sentry message so this fallback is easier to find than the generic spawn error
- tag the event with app/build/config-resolution metadata
- include spawn failure details in a JSON extra, preserving empty stdout/stderr as `""`
- add a changelog entry

## Test plan
- `yarn workspace eas-cli typecheck`
- `git diff --check`
2026-05-20 19:37:12 +02:00
Expo CI 51fb25c689 update CHANGELOG.md 2026-05-20 15:31:46 +00:00
Expo CI 81cfeede9a v19.0.4 2026-05-20 15:28:33 +00:00
Stanisław Chmiela 4bff24b71c [eas-cli] Update eas-cli observe GraphQL types (#3762)
## Summary
- update eas-cli GraphQL schema/types for the new observe `uniqueActiveUserCount` field
- add the new `lastSeenUserCount` generated field from the PR diff

## Validation
- `yarn run -T oxfmt packages/eas-cli/src/graphql/types/Observe.ts packages/eas-cli/src/commands/observe/__tests__/versions.test.ts packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts packages/eas-cli/src/graphql/generated.ts packages/eas-cli/graphql.schema.json`
- `yarn workspace eas-cli typecheck`
2026-05-20 17:24:20 +02:00
Expo CI 503b62b73d v19.0.3 2026-05-20 15:02:48 +00:00
Mustafa Abban aaab3c98a1 [build-tools] place gradle profile step after run gradle (#3761)
* [build-tools] place gradle profile step after run gradle

* [build-tools] place gradle profile step after run gradle
2026-05-20 11:00:29 -04:00
Expo CI b64f25b36d v19.0.2 2026-05-20 14:39:34 +00:00
Ash Wu 1e209b00a8 [build-tools] Reduce xcactivitylog parser output size (#3760)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 22:29:59 +08:00
Stanisław Chmiela 6ec2ea0530 [eas-cli] Capture iOS config introspection fallback in Sentry (#3759)
## Summary
- capture local Expo CLI iOS entitlements introspection failures in Sentry before falling back to bundled config resolution
- tag the event with `app_id` from `EAS_BUILD_PROJECT_ID` and the config resolution path
- capture a short fallback message so this is easier to distinguish from generic spawn failures
- include the failed command stdout, stderr, and exit status as Sentry extras

## Why
If `expo config --json --type introspect` fails but the bundled config fallback succeeds, users no longer hit a hard failure. Capturing the original command failure gives us visibility into the remaining fallback cases without breaking builds, and the explicit message makes these events easier to identify.

## Validation

CI should pass.
2026-05-20 15:51:37 +02:00
Szymon Dziedzic d5a6b13dae [eas-cli] Add --json mode to simulator:start (#3751)
# Why

`eas simulator:start` currently only emits human-readable connect instructions, which is awkward to consume from scripts/automation. `--json` (which implies `--non-interactive`) makes the session id, job run URL, and remote config directly parseable.

# How

- Swapped `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags` and resolved both via `resolveNonInteractiveAndJsonFlags`, matching the pattern used by `eas upload` and other JSON-capable commands.
- Calls `enableJsonOutput()` early so spinners/log lines redirect to stderr when JSON is enabled.
- After the remote config is ready, emits a single JSON payload (`deviceRunSessionId`, `type`, `jobRunUrl`, `remoteConfig`) and returns without entering the interactive wait loop.
- Existing `--non-interactive` (without `--json`) behavior is preserved.

# Test Plan

- `yarn typecheck` reports no new errors (the two pre-existing `src/build/metadata.ts` errors are unrelated to this PR).
- `yarn run -T oxlint packages/eas-cli/src/commands/simulator/start.ts` → 0 errors.
- `yarn run -T oxfmt --check packages/eas-cli/src/commands/simulator/start.ts` → clean.
- Manual (TODO before merge): run `eas simulator:start --platform ios --json`, confirm a single JSON object lands on stdout while spinners/log output go to stderr, and that the payload contains the expected fields.
2026-05-20 12:47:00 +02:00
Ash Wu 41eb9f1499 [build-tools] Enable build time statistics for xcodebuild (#3750)
* [build-tools] Enable build time statistics for xcodebuild

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:45:59 +08:00
Ash Wu 5c9b93fc34 [build-tools] Pass ctx.env to parse xcactivitylog (#3758)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:08:55 +08:00
Ash Wu fbc57831cc [build-tools] Use pre-install xclogparser (#3747)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 09:40:19 +08:00
Mustafa Abban 34ca7b0ab9 [build-tools] ignore no Gradle profile found error (#3755)
* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error
2026-05-19 15:42:33 -04:00
Expo CI 7d6e4db4e8 update CHANGELOG.md 2026-05-19 18:28:20 +00:00
Expo CI 32355274ec v19.0.1 2026-05-19 18:26:02 +00:00
Stanisław Chmiela 50ca4cbec1 [eas-cli] Restore iOS entitlements introspection fallback (#3753)
## Summary
- wrap managed iOS `expo config --json --type introspect` entitlement resolution in a local Expo CLI try/catch
- fall back to bundled `getPrebuildConfigAsync` + `compileModsAsync`, matching the previous behavior
- include stderr/stdout in the thrown error when both the local CLI and fallback fail
- check `isExpoInstalled` before spawning the local Expo CLI, consistent with the regular config reader

## Why
After `18.13.1`, managed iOS builds fail hard if the local Expo CLI introspection command exits non-zero. Previously this path could still resolve entitlements through the bundled config fallback. Restoring that fallback limits the regression while preserving the local CLI path when it is available.

## Validation

CI should pass.
2026-05-19 20:24:00 +02:00
Stanisław Chmiela 2dbb4372d8 [eas-cli] Add EXPO_NO_DOTENV when resolving iOS entitlements back (#3752)
## Summary
- restore `EXPO_NO_DOTENV=1` when resolving managed iOS entitlements through `expo config --type introspect`
- keep the introspection environment consistent with the regular Expo config path

## Why
`18.13.1` changed this path to call the local Expo CLI directly, but the entitlements-specific call no longer disabled dotenv loading. This can make the iOS-only entitlements introspection evaluate app config under a different environment than the rest of EAS CLI config resolution.

## Validation

CI should pass. Repacks should work.
2026-05-19 20:14:45 +02:00
Expo CI 4bf38452a9 update CHANGELOG.md 2026-05-19 16:42:12 +00:00
Expo CI fc7c2950e7 v19.0.0 2026-05-19 16:38:56 +00:00
Byron Karlen 30248528ad [auth] Make browser-based login the default (#3746) 2026-05-19 09:19:39 -07:00
Mustafa Abban ecf58ab8a8 [build-tools] - Gradle build profile report with task execution breakdown (#3629)
* [build] gradle parse profiler

* gradleProfileTasks

* individual tasks nested underneath with tree characters

* filter out whats less than 1s

* parseGradleProfile

* profile flag

* enable by default

* gradleProfile utils

* parse profile HTML with fast-xml-parser

* parse profile HTML with fast-xml-parser

* error handling, send to sentry

* sentry capture
2026-05-19 12:07:36 -04:00
Douglas Lowder 10c7ac64ba [eas-cli] filter observe:routes results by route names (#3744)
# Why

The `eas observe:routes` command lacked the ability to filter navigation route results by specific route names. Users had no way to narrow down the routes data to only the routes they cared about.

# How

A new `--route-name` flag (repeatable) was added to the command. When one or more values are provided, they are collected into a `routeNames` array and forwarded to the `fetchObserveNavigationRoutesAsync` function, which passes them along in the GraphQL filter. An empty array is treated the same as omitting the flag — no `routeNames` key is sent in the filter.

Example:

```
$ easd observe:routes 
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (63)    0.08s (217)   0.22s (280)
/task/[taskId]  0.55s (39)    0.17s (139)   0.35s (178)
/new            0.37s (20)    0.13s (83)    0.26s (103)
/stats          0.88s (19)    0.36s (94)    0.64s (113)
/settings       0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (106)   0.08s (504)   0.22s (610)
/task/[taskId]  0.51s (88)    0.18s (325)   0.34s (413)
/stats          1.06s (68)    0.34s (243)   0.62s (311)
/new            0.32s (67)    0.13s (219)   0.27s (286)
/settings       0.16s (17)    0.06s (87)    0.13s (104)

$ easd observe:routes --route-name /new --route-name /settings
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.37s (20)    0.13s (83)    0.26s (103)
/settings  0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.32s (67)    0.13s (219)   0.27s (286)
/settings  0.16s (17)    0.06s (87)    0.13s (104)
```

# Test Plan

- New unit tests cover both the command flag parsing and the fetch function behavior
- Manual testing with the kadiexpo/cadence app in staging
2026-05-19 03:50:12 -07:00
Phil Pluckthun 7ee81e856b fix(eas-cli): Check if expo is resolvable before assuming it's installed (#3748)
* Add resolution check to `isExpoCommand` shared with `spawnExpoCommand`

* Add CHANGELOG entry
2026-05-19 11:02:14 +01:00
Gabe Debes 8328172827 [worker] report build phase duration to www metrics endpoint (#3732)
# Why

Turtle reports build-phase durations to Datadog. Orchestrator-routed builds don't, so we lose those stats post-migration.

Ref ENG-21208

# How

Adds a `Datadog` helper in `@expo/build-tools`, following the existing Sentry singleton pattern. Worker configures it once per task with the www API base URL, turtle build id, and robot token when a token is available. `BuildContext` emits `eas.build.phase_duration` distribution samples when build phases end, while the existing build phase stats callback remains only for launcher websocket reporting.

The payload matches what shipped in https://github.com/expo/universe/pull/27475: `POST /v2/turtle-builds/:buildId/metrics` with `metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value, tags }]`. Upload failures are captured through Sentry and never fail the build. Worker flushes pending metric sends before sending terminal build results.

# Test Plan

- CI
- `yarn exec oxfmt --check CHANGELOG.md packages/build-tools/src/datadog.ts packages/build-tools/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/__tests__/datadog.test.ts packages/build-tools/src/__tests__/context.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/__unit__/service.test.ts`
- `yarn workspace @expo/build-tools build`
- `yarn workspace @expo/build-tools jest-unit src/__tests__/datadog.test.ts src/__tests__/context.test.ts --runInBand`
- `yarn workspace @expo/worker test:unit packages/worker/src/__unit__/service.test.ts --runInBand`
2026-05-19 11:13:53 +02:00
Expo CI 29909b223d update CHANGELOG.md 2026-05-18 21:34:02 +00:00
Expo CI dbf597957d v18.13.1 2026-05-18 21:15:12 +00:00
Mustafa Abban 4d2b406dc5 [build-job] add GRADLE_PROFILE build phase enum (#3743) 2026-05-18 16:22:22 -04:00
Douglas Lowder 774195ff74 [eas-cli] Add direct dependency on debug (#3741)
# Why

The `Log.debug()` helper in `src/log.ts` has been calling `require('debug')` under an `eslint-disable` comment, relying on the package being resolved transitively through `@babel/*` and other dependencies.

# How

Declare `debug` and `@types/debug` directly so the require is stable under stricter resolution modes and so we can drop the `eslint-disable` and the runtime require in favor of a typed import.

Fixes #3630 .

# Test Plan

- Unit tests pass
- Tested various commands and tested with `DEBUG=eas:*`
2026-05-18 12:37:09 -07:00
Phil Pluckthun 255590cd45 refactor(eas-cli): Remove npx expo invocations and config/entitlements fallbacks (#3282)
* Expose raw `spawnExpoCommand` utility

* Remove use of `npx expo` invocation for `spawnExpoCommand`

* Replace manual `npx expo` invocation in onboarding with `expoCommandAsync`

* Remove npx invocation in "new" command

* Remove catch-case from `getExpoConfigInternalAsync`

* Remove catch-case from `getManagedApplicationTargetEntitlementsAsync`

* Force fallback to `@expo/config` in tests

This isn't great and shouldn't be done, but otherwise we're mocking the
expo command utility here which also isn't great

* Update commands.test.ts

* Add CHANGELOG entry

* Apply lints
2026-05-18 11:59:30 +01:00
Szymon Dziedzic d15641948c Improve tunnel perf by using constrained stream quality and dimensions (#3737)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Use parameters to improve tunnel perf.

# How

Use quic cloudflared tunnel protocol + constrain stream max dimension + quality to improve speed.

# Test Plan

Tested locally.
2026-05-18 11:54:14 +02:00
Stanisław Chmiela 6cbcc8537c [eas-cli] Add expoPackageVersion to build metadata (#3725)
# Why

Persist the installed `expo` package version separately from `sdkVersion` so server-side log uploads can reproduce the legacy `expo_package_version` Datadog tag without conflating it with SDK version.

# How

- Added `expoPackageVersion` to build metadata schema/types.
- Moved the existing worker `getExpoPackageVersionAsync(...)` behavior into shared `@expo/eas-build-job#getInstalledExpoPackageVersionAsync(...)`: `node --print require.resolve('expo/package.json')`, `fs.readJson`, semver validation, and the same `EAS_BUILD_EXPO_PACKAGE_VERSION_*` `UserError`s.
- `collectMetadataAsync(...)` now resolves `expoPackageVersion` inline with `asyncResult(getInstalledExpoPackageVersionAsync(...)).value`, so build creation and the existing internal `updateBuildMetadata` path send it alongside the rest of the build metadata.
- Worker error-log tags now read `expo_package_version` from build metadata; the worker no longer resolves or mutates this field.

# Test Plan

CI should pass.
2026-05-15 16:32:13 +02:00
Expo CI 5ec4eaf052 update CHANGELOG.md 2026-05-14 20:15:30 +00:00
Expo CI 08a9b5ff7f v18.13.0 2026-05-14 20:13:13 +00:00
Douglas Lowder 501af5213e [eas-cli] Extract shared observe flag groups into observe/flags.ts (#3731)
# Why

It was found that observe commands were using common flag definitions with duplicate code.

# How

Refactored the observe commands to extract flag definitions.

# Test Plan

Commands tested locally.

Unit tests pass.
2026-05-14 13:10:13 -07:00
Douglas Lowder d48f931d77 [eas-cli] Add observe:routes for navigation metrics by route name (#3730)
# Why

New command `observe:routes` to get navigation metrics by route.

```
USAGE
  $ eas observe:routes [--platform android|ios] [--metric cold_ttr|warm_ttr|nav_tti...] [--stat
    median|med|p90|count|event_count|eventCount...] [--after <value>] [--limit <value>] [--start <value> | --days
    <value>] [--end <value> | ] [--app-version <value>] [--update-id <value>] [--build-number <value>] [--project-id
    <value>] [--json] [--non-interactive]

FLAGS
  --after=<value>         Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
  --app-version=<value>   Filter by app version
  --build-number=<value>  Filter by app build number
  --days=<value>          Show results from the last N days (mutually exclusive with --start/--end)
  --end=<value>           End of time range (ISO date)
  --json                  Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --limit=<value>         The number of items to fetch each query. Defaults to 50 and is capped at 200.
  --metric=<option>...    Navigation metric to display (can be specified multiple times). Defaults to all three.
                          <options: cold_ttr|warm_ttr|nav_tti>
  --non-interactive       Run the command in non-interactive mode.
  --platform=<option>     Filter by platform
                          <options: android|ios>
  --project-id=<value>    EAS project ID (defaults to the project ID of the current directory)
  --start=<value>         Start of time range (ISO date)
  --stat=<option>...      Statistic to display per metric (can be specified multiple times)
                          <options: median|med|p90|count|event_count|eventCount>
  --update-id=<value>     Filter by EAS update ID

DESCRIPTION
  display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name

```

# How

Added a new command that that queries the new navigationRoutes GraphQL field to display Cold TTR, Warm TTR, and TTI, broken down per route name and platform.

# Test Plan

Tested with staging data in `@expokadi/cadence` project.

New unit tests added.
2026-05-14 12:11:38 -07:00
Mustafa Abban 99b18a2189 [worker] - remove logging errors to datadog (#3729)
* [worker] - remove logging errors to datadog

* [worker] - remove logging errors to datadog

* rem tests

* rem tests
2026-05-13 14:09:38 -04:00
Expo CI 9ece20157f update CHANGELOG.md 2026-05-13 14:21:35 +00:00
Expo CI 985cd9d52f v18.12.3 2026-05-13 14:16:02 +00:00
Szymon DziedzicandClaude Opus 4.7 576c731d87 [eas-cli] Show web preview URL for agent-device simulator sessions (#3728)
# Why

The build-tools side of agent-device remote sessions now reports `agentDeviceRemoteSessionUrl`, `agentDeviceRemoteSessionToken`, and (on iOS only) `webPreviewUrl` via `startDeviceRunSession`. The eas-cli simulator commands still queried the old `url`/`token` fields and didn't surface the new web preview URL at all.

# How

- Updated the `AgentDeviceRunSessionRemoteConfig` selection in `DeviceRunSessionQuery` to read `agentDeviceRemoteSessionUrl`, `agentDeviceRemoteSessionToken`, and `webPreviewUrl`.
- `formatRemoteSessionInstructions` now uses the new field names for the daemon env-var exports, and — when `webPreviewUrl` is present — appends a "Open the following URL in your browser to preview the simulator" block. Android sessions (where `webPreviewUrl` is null) get the daemon-only output as before.
- Regenerated `graphql.schema.json` and `generated.ts` against staging (`EXPO_STAGING=1 yarn generate-graphql-code`).

# Test Plan

- [ ] `yarn typecheck` and `yarn lint` pass.
- [ ] `eas simulator:start --platform ios --type agent-device` — verify the output includes both the daemon exports and the web preview URL.
- [ ] `eas simulator:start --platform android --type agent-device` — verify the output includes only the daemon exports (no web preview block).
- [ ] `eas simulator:get --id <id>` — same checks against an existing session.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 15:45:41 +02:00
Ash Wu efe26a729e [build-tools] Route xcactivitylog parser failures to Sentry (#3708)
Signed-off-by: Ash Wu <[email protected]>
2026-05-13 21:25:50 +08:00
Szymon DziedzicandClaude Opus 4.7 3dcfef1e2c [build-tools] Send web preview URL in agent-device remote session config (#3727)
# Why

Agent-device remote sessions on iOS need to expose a web preview URL alongside the device tunnel so clients can render the simulator preview. The config we report to the API server also lacked semantically-named keys.

# How

- On Darwin, after the agent-device daemon + cloudflared tunnel are up, also launch `serve-sim --tunnel` and capture its preview URL.
- Report `{ agentDeviceRemoteSessionUrl, agentDeviceRemoteSessionToken, webPreviewUrl? }` via `startDeviceRunSession`. `webPreviewUrl` is omitted on Linux/Android (serve-sim is iOS-only).
- Extract the serve-sim launch + URL-wait into a shared helper (`startServeSimWithTunnelAsync`) so `startServeSimRemoteSession` and `startAgentDeviceRemoteSession` share one implementation.

# Test Plan

- [ ] `yarn typecheck` and `yarn lint` pass.
- [ ] Manual: iOS device run session — verify the API server receives `agentDeviceRemoteSessionUrl`, `agentDeviceRemoteSessionToken`, and `webPreviewUrl`.
- [ ] Manual: Android device run session — verify `webPreviewUrl` is omitted but the agent device URL + token are present.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 14:58:52 +02:00
Ash Wu c0e86f837e [build-tools] Move Sentry SDK from worker to build-tools as a singleton (#3712)
* [build-tools] Move Sentry SDK from worker to build-tools as a singleton

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-13 20:35:07 +08:00
Gabe Debes ae65e74ed0 Fix CHANGELOG for version 18.12.2 (#3723)
Fix bug fixes for version 18.12.2; Wrong changelog entry from https://github.com/expo/eas-cli/pull/3657
2026-05-12 15:28:53 -07:00
Expo CI 2ba012bb30 update CHANGELOG.md 2026-05-12 22:02:59 +00:00
Expo CI 48c47bcc0c v18.12.2 2026-05-12 22:00:39 +00:00
Gabe Debes 6ad59488b0 [eas-cli] eas go server-side project setup and SDK version selection (#3657)
* eas go: server-side project config, in-memory tar.gz upload, repack workflow dispatch

.

* eas go: remove unused uploadAccountScopedBufferToGCSAsync and its tests

* eas go: restore import * as style for fs, os, path

* eas go: use fs-extra methods and restore fail() on spinner errors

* fix fmt

* eas go: restore spinner failure messages

* eas go: address review comments

* eas go: clean up minor issues

* eas go: use primaryAccount, fix noVcs test expectations
2026-05-12 13:54:41 -07:00
Brent Vatne c476f07818 [gha] Audit workflows (#3718) 2026-05-12 12:46:30 -07:00
Expo CI 329a02d06f update CHANGELOG.md 2026-05-12 15:27:09 +00:00
Expo CI f46b469267 v18.12.1 2026-05-12 15:24:18 +00:00
Szymon Dziedzic d708aaefab Add --type serve-sim to eas simulator:start (#3711)
* [build-tools] Add eas/start_serve_sim_remote_session function

# Why

Adds a new EAS build step function that starts a serve-sim server with a Cloudflare tunnel and reports the preview/stream tunnel URLs back to the API server as the remote config for a DeviceRunSession. Mirrors the existing eas/start_agent_device_remote_session flow.

# How

- Adds eas/start_serve_sim_remote_session in packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts. It:
  - Selects Xcode and ensures cloudflared is installed via Homebrew.
  - Launches npx serve-sim-szdziedzic@latest --tunnel detached, with stdout/stderr captured to /tmp/serve-sim/serve-sim.log.
  - Polls the log for the Tunnel: and Stream: URLs (both *.trycloudflare.com), with a 60s timeout.
  - Calls StartDeviceRunSession with remoteConfig: { previewUrl, streamUrl }, then blocks so the tunnel stays alive until the session is stopped.
  - Declares supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN] so it only runs on iOS/macOS runners.
- Extracts the duplication shared with start_agent_device_remote_session into a new packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts module (StartDeviceRunSession mutation, getDeviceRunSessionIdOrThrow, reportRemoteSessionConfigAndBlockAsync, spawnDetachedAsync, ensureBrewPackageInstalledAsync, readFileOrEmptyAsync). Both step functions now consume the shared helpers.
- Registers the new function in packages/build-tools/src/steps/easFunctions.ts.

# Test Plan

- yarn typecheck, yarn lint, yarn fmt:check all pass for the changed files.
- Not yet exercised end-to-end — requires a DEVICE_RUN_SESSION_ID injected by the API server. Once the server side is ready, smoke-test by triggering a device run session against an iOS/macOS runner and verifying that previewUrl/streamUrl show up on the session and the tunnel URLs are reachable.

* [eas-cli] Support --type serve-sim in eas simulator:start

# Why

Now that the API server exposes DeviceRunSessionType.ServeSim and ServeSimRunSessionRemoteConfig (previewUrl, streamUrl), wire up the CLI to actually launch a serve-sim session and tell the user how to use it. Companion to the build-tools step function in the previous PR.

# How

- Add ... on ServeSimRunSessionRemoteConfig { previewUrl streamUrl } to DeviceRunSessionByIdQuery and regenerate types.
- Register DeviceRunSessionType.ServeSim → 'serve-sim' in the flag-value map in eas simulator:start. Because the map is typed Record<DeviceRunSessionType, string>, the build was already failing on the missing entry (this is what was breaking verify-graphql-code in CI).
- Replace formatRemoteConfigShellSnippet with formatRemoteSessionInstructions in simulator/utils.ts. The switch over remoteConfig.__typename is now exhaustive: agent-device returns the export … shell snippet (unchanged); serve-sim returns "🌐 Open the following URL in your browser to access the simulator:" followed by previewUrl.
- start.ts and get.ts call the new function, so the headline now lives inside the formatter and stays variant-appropriate.
- Rename the user-facing "${type} daemon" copy to "${type} session" so it reads naturally for both agent-device and serve-sim.
- Tweak the command description to drop the agent-device-specific phrasing.

# Test Plan

- yarn typecheck, yarn lint, yarn fmt:check, yarn verify-graphql-code all pass locally.
- Smoke test by running eas simulator:start --platform ios --type serve-sim once the server side has rolled out and the build-tools step from the parent PR is published. Verify the CLI prints the preview URL and that opening it in a browser loads the simulator.
2026-05-12 16:52:22 +02:00
Szymon Dziedzic 0d1c91aad2 [eas-cli] Add eas simulator:get command (#3710)
# Why

We need a way to inspect an existing remote simulator session and retrieve its
shell credentials again — for example after losing the original terminal output
from `eas simulator:start`. Without this, the only way to re-attach is to start
a new session.

# How

- Adds a hidden `eas simulator:get --id <ID>` command that fetches the device
  run session by ID.
- Always prints session id, type, status, job run id, job run status, and job
  run URL (blank when the turtle job run is not available yet).
- Prints the shell snippet to attach to the session only when the session is
  `IN_PROGRESS` and `remoteConfig` is present.
- Extracts `formatRemoteConfigShellSnippet` (and the
  `DeviceRunSessionRemoteConfig` type) into `src/simulator/utils.ts` so
  `start.ts` and the new `get.ts` can share it. The helper lives outside
  `src/commands/` so oclif does not try to treat it as a command.
- Expands `DeviceRunSessionByIdQuery` with the app's `slug` and
  `ownerAccount.name` so the job-run URL can be built from the session
  response.

# Test Plan

Ran against a running session:

```
$ easd simulator:get --id 019e1c30-465b-7fd9-98a1-eb0ceaafafb5
✔ Fetched device run session 019e1c30-465b-7fd9-98a1-eb0ceaafafb5

ID:               019e1c30-465b-7fd9-98a1-eb0ceaafafb5
Type:             AGENT_DEVICE
Status:           IN_PROGRESS
Job run ID:       019e1c30-465b-75e1-b746-a528054649e9
Job run status:   IN_PROGRESS
Job run URL:      https://expo.dev/accounts/expo/projects/testapp/job-runs/019e1c30-465b-75e1-b746-a528054649e9

🔑 Run the following in your shell to attach to the session:

export AGENT_DEVICE_DAEMON_BASE_URL='...'
export AGENT_DEVICE_DAEMON_AUTH_TOKEN='...'
```

- `yarn typecheck`, `yarn lint`, and `yarn fmt:check` pass.
- Verified the previous `command simulator:utils not found` warning is gone
  (helper now lives outside `commands/`).
2026-05-12 15:37:45 +02:00
Expo CI 4f670f90b6 update CHANGELOG.md 2026-05-12 12:22:37 +00:00
Expo CI 53d294330d v18.12.0 2026-05-12 12:19:03 +00:00
Szymon Dziedzic ed4b39c55e [eas-cli] Read agent device remote config from API instead of scraping logs (#3698)
* [eas-cli] Read agent device remote config from API instead of scraping logs

Migrate `eas simulator:start` from greppping `export AGENT_DEVICE_DAEMON_*=…`
lines out of turtle job logs to reading the typed `remoteConfig` field on
`DeviceRunSession` via GraphQL.

The `DeviceRunSessionByIdQuery` now selects:

  remoteConfig {
    __typename
    ... on AgentDeviceRunSessionRemoteConfig { url token }
  }

…and the readiness poll succeeds as soon as `session.remoteConfig` is
non-null. The per-type `ReadinessChecker` indirection and all the log
fetch/parse helpers (fetchLogMessagesAsync, extractLogMessage,
extractExportedEnvValue, escapeRegExp, …) are removed; a small
`formatRemoteConfigShellSnippet` switch on `__typename` replaces them and
preserves the existing `export AGENT_DEVICE_DAEMON_*=…` user-facing
output.

Pairs with the producer-side change in expo/eas-cli#3696 — that PR
removes the `logger.info('export AGENT_DEVICE_DAEMON_*=…')` lines this
command previously scraped. Without this consumer migration, anyone on a
worker image that includes #3696 would hit POLL_TIMEOUT_MS waiting for
log lines that no longer exist. Also picks up an incidental
`yarn generate-graphql-code` regen of `generated.ts` /
`graphql.schema.json`.

* improvements after cr
2026-05-12 14:16:28 +02:00
Szymon Dziedzic a04d336921 [build-tools] Report agent device remote config via startDeviceRunSession (#3696)
* [build-tools] Report agent device remote config via startDeviceRunSession

Take CustomBuildContext so the eas/start_agent_device_remote_session step
can use ctx.graphqlClient (already authenticated with the scoped JWT from
job.secrets.robotAccessToken), matching the maestro reporter pattern.
After the cloudflared tunnel is up, call startDeviceRunSession(
deviceRunSessionId, { url, token }) using DEVICE_RUN_SESSION_ID from the
job env, instead of just printing the credentials. Throw if the env is
missing — this step is only meant to run as part of an
API-server-created device run session.

* use system error
2026-05-12 13:03:23 +02:00
Ash Wu f83d3b8ee0 [build-tools] rename smart_retry to retry_failed_only for maestro job (#3709)
Signed-off-by: Ash Wu <[email protected]>
2026-05-12 17:44:13 +08:00
Brent Vatne 9b5f237bf2 Fix .yarnrc.yml formatting 2026-05-11 19:33:48 -07:00
Brent Vatne 66cc6d2cf9 chore: Enforce minimum age for npm packages 2026-05-11 18:38:15 -07:00
Mustafa Abban d9ff560dab [build-internal] - handle errors when running internal build (#3706)
* [build-internal] - handle errors when running internal build

* [build-internal] - handle errors when running internal build

* [build-internal] - handle errors when running internal build
2026-05-11 13:15:49 -04:00
Ash Wu 1f53177ecf [build-tools] bump xclogparser default version (#3703)
Signed-off-by: Ash Wu <[email protected]>
2026-05-11 21:55:38 +08:00
Ash Wu b1f606979a [build-tools] improve xcactivitylog output (#3702)
Signed-off-by: Ash Wu <[email protected]>
2026-05-11 21:37:01 +08:00
Ash Wu 71f968e971 [build-tools] add smart_retry to maestro job (#3688)
Signed-off-by: Ash Wu <[email protected]>
2026-05-11 21:18:51 +08:00
Szymon Dziedzic 687e8897c1 [build-tools] Add Android support to start_agent_device_remote_session (#3697)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`eas/start_agent_device_remote_session` only worked on macOS (iOS) workers — it ran `xcode-select` and used `brew` to install `cloudflared`, so an Android workflow that tried to call it would fail. We want the same agent-device remote session functionality on Android (Linux) workers so a single step can drive both platforms with identical outputs.

# How

- Made the function platform-aware by reading `global.runtimePlatform`.
- Darwin path is unchanged: `xcode-select`, then `brew install cloudflared`.
- Linux path: skip `xcode-select` and, if `cloudflared` isn't already on `PATH`, download it from GitHub releases via `sudo curl` into `/usr/local/bin/cloudflared` (arch-aware: `linux-amd64` / `linux-arm64`) and `chmod +x`.
- `bun` is pre-installed on both worker images, so no install logic is needed.
- Daemon launch (`bun run src/daemon.ts`), `~/.agent-device/daemon.json` polling, the cloudflared tunnel, and the emitted `AGENT_DEVICE_DAEMON_BASE_URL` / `AGENT_DEVICE_DAEMON_AUTH_TOKEN` are unchanged — same final effect on both platforms.

# Test Plan

- `yarn typecheck` — clean
- `yarn lint` — 0 new errors (pre-existing warnings only)
- `yarn fmt:check` — clean
- `yarn test` in `packages/build-tools` — 480 tests pass
- Still to verify on a real Android worker: confirm `cloudflared` gets installed on a fresh image, the daemon writes credentials, the tunnel returns a `*.trycloudflare.com` URL, and the exported env vars work end-to-end against a connected Android device/emulator.
- iOS path on macOS workers should be unaffected (no behavioral change).
2026-05-11 14:24:54 +02:00
Stanisław Chmiela 0d5ef2ee89 [eas-build-job] Preserve workflow job outputs (#3704)
## Summary

This adds a shared `JobOutputs` type/schema and preserves declared workflow job outputs in Android, iOS, and generic job definitions.

Before this change, `outputs` was stripped from several build job shapes during Joi validation, including normal build jobs and custom builds that use `customBuildConfig.path`. That meant downstream worker code could not upload `jobs.<name>.outputs` for workflow `type: build` jobs.

## Changes

- Add shared `JobOutputs`, `JobOutputsSchema`, and `JobOutputsSchemaZ` definitions in `common.ts`.
- Use the shared output type/schema from Android, iOS, and Generic job definitions.
- Validate `outputs` directly on the Android and iOS top-level job schemas.
- Remove output handling from `CustomBuildConfigSchema`, so that schema only controls custom config vs inline step shape.
- Add Android and iOS schema coverage for normal build jobs, config-path custom jobs, and iOS resign jobs.

## Validation

- `packages/eas-build-job`: `yarn test android.test.ts ios.test.ts generic.test.ts`
- `packages/eas-build-job`: `yarn build`
- `yarn oxfmt --check ...`
2026-05-11 13:56:44 +02:00
Stanisław Chmiela f856d4022f Enable precompiled modules proxying for pod install (#3681) 2026-05-11 10:21:35 +02:00
Juwan Wheatley 5585ef99c8 [eas-cli] set the Convex env var on EAS automatically (#3685)
# Why

In our Convex docs (https://docs.expo.dev/guides/using-convex/#save-the-convex-url-as-an-eas-environment-variable), we tell people to set an ENV var with EAS to use in their deployments. With our Convex integration, we can automate this step and make the setup UX even smoother. 

Fixes ENG-21068

# How

I added a step to the convex:connect command to set the `EXPO_PUBLIC_CONVEX_URL` EAS env var. If it already exists, we prompt the user to prevent accidental rewrites. 

# Test Plan

`yarn build` and using the local `easd` alias, run `EXPO_STAGING=true easd integrations:convex:connect`

You should see the new output talking about the env var, and the env var should also be visible in the env var dashboard page:

<img width="1564" height="264" alt="Screenshot 2026-05-08 at 04 29 16" src="https://github.com/user-attachments/assets/b9a0340b-8993-4198-b9c0-971fabe464e0" />

<img width="656" height="162" alt="Screenshot 2026-05-08 at 04 29 35" src="https://github.com/user-attachments/assets/a3721b98-30b3-4055-b8a2-276760605c4c" />
2026-05-09 05:51:47 +09:00
Juwan Wheatley 4eb0753a06 [eas-cli] recommend that users read the docs after connecting Convex (#3683)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We should direct users to places to learn more about using Convex after connecting the integration.   
  
Fixes ENG-21071

# How

I added some extra output that links to both our docs quickstart and the Convex docs.

# Test Plan

`yarn build` and using the local `easd` alias, run `EXPO_STAGING=true easd integrations:convex:connect`  
  
You should see the new text content at the end:

![Screenshot 2026-05-07 at 21.40.41.png](https://app.graphite.com/user-attachments/assets/132a9115-c0d5-4f2b-aaa2-c08354ac3a6d.png)
2026-05-09 05:32:53 +09:00
Ash Wu bfaff08894 [build-tools] Fix ai-*.json is not guarenteed in Maestro 2.5 (#3639)
* [build-tools] Fix ai-*.json is not guarenteed in Maestro 2.5

* fix param name & use zod according to previous comments

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-07 14:51:23 +08:00
Mustafa Abban 64874f7dfe [build-tools] route to api v2 endpoint (#3680) 2026-05-06 14:48:19 -04:00
Mustafa Abban daa4c60e9b [build-tools] Log Gradle cache restore events to Datadog (#3674)
* [build-tools] Log Gradle cache restore events to Datadog

* route

* update routes

* remove key tags

* remove key tags

* tags
2026-05-06 13:10:39 -04:00
Stanisław Chmiela ba4e4eddbc [build-tools] Update minimum precompiled modules Expo version (#3677)
<img width="501" height="112" alt="Zrzut ekranu 2026-05-5 o 17 02 16" src="https://github.com/user-attachments/assets/7ee40f33-af83-4504-b1d9-b088674d7e81" />
2026-05-06 10:26:43 +02:00
Expo CI 6bda9c7fa0 update CHANGELOG.md 2026-05-05 16:42:27 +00:00
Expo CI 4dec5b3765 v18.11.0 2026-05-05 16:39:51 +00:00
Szymon Dziedzic 0e3216e681 [eas-cli] Hold eas simulator:start until session ends or Ctrl+C (#3676) 2026-05-05 18:35:29 +02:00
Matheus Michels 2a191700f7 [eas-cli] Add simulator flag to build dev (#3637) 2026-05-05 10:51:36 +01:00
Ash Wu 4da5b40520 [build-tools] Add eas/run_maestro_tests step with smart retry (#3635)
* [build-tools] Add smart retry to eas/run_maestro_tests

In `reuse_devices` mode, narrow the flow_paths to only the failing flows
on retry. After a failed attempt, parse the per-attempt JUnit XML to
identify the flows that failed and pass just those to the next maestro
invocation.

Falls back to retrying everything when the JUnit cannot be trusted
(missing testcases, conflicting names, malformed XML).

Replaces copy-latest with mergeJUnitReports — picks the latest attempt's
result per flow across retries — so the merged final report reflects the
best outcome each flow achieved. Falls back to copy-latest on data errors.

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-05 17:27:20 +08:00
Szymon Świerk d23248e4ac [eas cli] Tar creation: force the owner write bit on for directories (#3489)
* [eas cli] tar creation on windows: force the owner write bit on for directories

* add changelog

* merge branch main

* force write bit for all platforms, not windows only
2026-05-05 10:01:29 +02:00
Douglas Lowder 3318392620 [eas-cli] Add observe:logs command for viewing custom events (#3638)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add new command to retrieve custom events for Expo Observe.

```
USAGE
  $ eas observe:logs [EVENTNAME] [--platform android|ios] [--after <value>] [--limit <value>] [--start <value> |
    --days <value>] [--end <value> | ] [--app-version <value>] [--update-id <value>] [--session-id <value>]
    [--all-events] [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  [EVENTNAME]  Custom event name to filter by

FLAGS
  --after=<value>        Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
  --all-events           When no event name argument is provided, list all events across all event names instead of a
                         summary of event names + counts.
  --app-version=<value>  Filter by app version
  --days=<value>         Show events from the last N days (mutually exclusive with --start/--end)
  --end=<value>          End of time range (ISO date)
  --json                 Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --limit=<value>        The number of items to fetch each query. Defaults to 10 and is capped at 100.
  --non-interactive      Run the command in non-interactive mode.
  --platform=<option>    Filter by platform
                         <options: android|ios>
  --project-id=<value>   EAS project ID (defaults to the project ID of the current directory)
  --session-id=<value>   Filter by session ID
  --start=<value>        Start of time range (ISO date)
  --update-id=<value>    Filter by EAS update ID

DESCRIPTION
  display individual custom events (logs) emitted by the app, filtered by the event name in the argument. With no
  arguments, a list of the available event names and associated event counts is returned.


```

# How

- Add new GraphQL query and fragment, and new formatter for the retrieved custom events
- New command `observe:logs`, designed to be similar to `observe:events`
- Did some refactoring so that observe commands reuse methods for formatting results

The command can return a list of the different event names found and their counts, a list of events for a specific name, or a list of all events found:

```
  ┌───────────────────────────┬───────────────────────────────────────────┐                                             
  │        invocation         │                  result                   │                                             
  ├───────────────────────────┼───────────────────────────────────────────┤                                             
  │ observe:logs              │ event-name summary table (names + counts) │                                             
  ├───────────────────────────┼───────────────────────────────────────────┤                                             
  │ observe:logs --all-events │ full list of events across all names      │                                             
  ├───────────────────────────┼───────────────────────────────────────────┤                                             
  │ observe:logs <event>      │ full list of events filtered by name      │                                             
  └───────────────────────────┴───────────────────────────────────────────┘  
```

# Test Plan

- New unit tests added
- Will test against Kadi's ingestion endpoint data
2026-05-04 13:24:31 -07:00
Juwan Wheatley b5fac2a066 [eas-cli] Fix Convex invite output (#3672) 2026-05-04 15:43:51 -04:00
Mustafa Abban 138f7973ae [eas-cli] Add lockfile existence preflight check for eas build (#3665) 2026-05-04 15:31:36 -04:00
Szymon Dziedzic 51311f1813 [eas-cli] stop device run session when time out happens (#3634) 2026-05-04 18:59:07 +02:00
Expo CI a85b60c11e update CHANGELOG.md 2026-05-04 09:47:52 +00:00
Expo CI cacd48bf66 v18.10.0 2026-05-04 09:45:14 +00:00
Stanisław Chmiela bc213a51b9 [eas-cli] Create portable project archives (#3234)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Project archives created on one platform can be extracted on another platform with incompatible metadata or mode bits. In particular, Windows read-only directories can be represented as read-only POSIX directories in the tarball, which can make worker extraction fail when child files need to be created.

Using `portable: true` consistently also strips owner/group metadata and avoids making Windows a special case.

Supersedes the approach in #3489 and avoids changing worker extraction as proposed in #3663.

# How

Create project tarballs with `portable: true` on all platforms.

# Test Plan

- `yarn workspace eas-cli test src/build/utils/__tests__/repository.test.ts`
- `yarn workspace eas-cli typecheck`
- `yarn run -T oxfmt CHANGELOG.md packages/eas-cli/src/build/utils/repository.ts packages/eas-cli/src/build/utils/__tests__/repository.test.ts`
2026-05-04 11:41:52 +02:00
Juwan Wheatley ddd8bb7fdd [eas-cli] Add Convex E2E runner (#3654)
# Why
The Convex integration needs a repeatable manual end-to-end check against the deployed API without putting flaky real-resource setup in CI.

# How
Adds `packages/eas-cli/scripts/convex-e2e.ts`, which bootstraps one fresh Expo app, runs the Convex connect/display/invite/dashboard/delete flow with the repo-local CLI, verifies env output and package install, and cleans up EAS-side links when safe. Usage and cleanup caveats live in the script header.

# Test Plan
Run locally from the top of the stack:
```sh
cd packages/eas-cli

yarn build

yarn ts-node scripts/convex-e2e.ts
```

Verification from the successful run:
```text
✔ Created @fiberjw/convex-e2e-molzwy56
✔ Installed the convex npm package
✔ Created Convex team fiberjw's team (Expo) / fiberjw-c7b29
✔ Created Convex project convex-e2e-molzwy56 with deployment zany-trout-356
✔ Sent Convex team invitation
✔ Wrote Convex config to .env.local
Verified .env.local contains Convex environment variables
Verified package.json includes convex@^1.37.0
Convex teams linked to @fiberjw
Team: fiberjw's team (Expo) / fiberjw-c7b29
Convex project linked to convex-e2e-molzwy56
✔ Opened https://dashboard.convex.dev/t/fiberjw-c7b29/convex-e2e-molzwy56
✔ Removed Convex project convex-e2e-molzwy56 from EAS servers
✔ Removed Convex team fiberjw's team (Expo) / fiberjw-c7b29 from EAS servers
Convex-side resources are not destroyed by this cleanup.
```
2026-05-01 18:45:20 +09:00
Juwan Wheatley ed251cb0d1 [eas-cli] Add Convex dashboard command (#3653)
# Why
Users need a quick way to open the Convex dashboard for the linked app project.

# How
Adds `eas integrations:convex:dashboard` to open the linked project dashboard, with empty-state and browser-open failure handling.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the no-project dashboard path from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:dashboard
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:40:54 +09:00
Juwan Wheatley 5176495e9e [eas-cli] Add Convex project delete command (#3652)
# Why
Users need a way to remove the EAS-side Convex project link for an Expo app.

# How
Adds `eas integrations:convex:project:delete`, with `--yes`, non-interactive handling, and prompts that include the Convex dashboard URL and state Convex-side resources are not destroyed.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the no-project delete path from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:project:delete --non-interactive --yes
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:35:42 +09:00
Juwan Wheatley 32211614eb [eas-cli] Add Convex project command (#3651)
# Why
Users need to inspect the Convex project linked to the current Expo app.

# How
Adds `eas integrations:convex:project` to display the app-linked Convex project with IDs, slugs, identifiers, team, and dashboard URL. The team line now uses the human-readable team name/slug first, then the Convex ID.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the command's no-project state from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:project
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:30:43 +09:00
Juwan Wheatley ce5b48244c [eas-cli] Add Convex team delete command (#3650)
# Why
Users need a way to remove stale EAS-side Convex team links.

# How
Adds `eas integrations:convex:team:delete`, with selection by ID/prompt, `--yes`, non-interactive handling, and prompts that state Convex-side resources are not destroyed.

# Test Plan
Local E2E rerun against the team link created in a fresh Expo app before project setup was API-throttled.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:team:delete 019dda27-c450-7647-8ce4-97124c421f91 --non-interactive --yes
$REPO/packages/eas-cli/bin/run integrations:convex:team
```

Output/verification:
```text
Team: fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
Invited email: [email protected]
Removing the Convex team link from EAS servers. This does not destroy resources on Convex: https://dashboard.convex.dev/t/fiberjw-ef4a2
✔ Removed Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548) from EAS servers
No Convex team is linked to account fiberjw on EAS.
```
2026-05-01 18:26:10 +09:00
Juwan Wheatley ece1d00ca7 [eas-cli] Add Convex team invite command (#3649)
# Why
Users may need to resend a Convex team invite if they missed the original email.

# How
Adds `eas integrations:convex:team:invite`, including previous invite metadata, recent-resend confirmation in interactive mode, and non-interactive warning behavior.

# Test Plan
Local E2E rerun against the team link created in a fresh Expo app before project setup was API-throttled.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:team:invite --non-interactive
```

Output/verification:
```text
Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
- Sending Convex team invitation to [email protected]
✔ Sent Convex team invitation to [email protected] for fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
Convex dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
```
2026-05-01 18:21:22 +09:00
Juwan Wheatley 82dd218a2b [eas-cli] Add Convex team command (#3648)
# Why
Users need to inspect Convex team links stored for the current Expo app owner account.

# How
Adds `eas integrations:convex:team` to list linked Convex teams with the human-readable team name/slug first, then the Convex ID, plus the EAS connection ID, dashboard URL, and invite metadata.

# Test Plan
Local E2E rerun against a fresh Expo app. Project setup is currently API-throttled, but team creation succeeded before setup was rejected, allowing the display command to be verified.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
npx create-expo-app@latest /tmp/eas-convex-e2e-teamfmt --yes
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run init --non-interactive --force
$REPO/packages/eas-cli/bin/run integrations:convex:connect --non-interactive --project-name e2eteamfmt || true
$REPO/packages/eas-cli/bin/run integrations:convex:team
```

Output/verification:
```text
✔ Created @fiberjw/eas-convex-e2e-teamfmt
✔ Project successfully linked (ID: 194b1351-323e-4b8d-a0ce-af93fee1ac74)
✔ Created Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
Convex teams linked to @fiberjw
Team: fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
```
2026-05-01 18:12:36 +09:00
Juwan Wheatley 9f763c6957 [eas-cli] Add Convex connect command (#3575)
# Why
Expo is integrating with Convex so users can provision an EAS-linked Convex backend from the EAS CLI.

# How
Adds `eas integrations:convex:connect`, which installs `convex`, creates or reuses an EAS-side Convex team link, sets up the app project, sends a verified-email invite with recent-resend confirmation, writes `CONVEX_DEPLOY_KEY` and `EXPO_PUBLIC_CONVEX_URL` to `.env.local`, and points users at `npx convex dev`.

This PR also includes the generated GraphQL types, Convex GraphQL helpers, shared Convex formatting/prompt utilities, and the changelog entry for the stack.

# Test Plan
Local E2E rerun after updating Convex team output formatting.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
npx create-expo-app@latest /tmp/eas-convex-e2e-3575-fmt --yes
cd /tmp/eas-convex-e2e-3575-fmt
$REPO/packages/eas-cli/bin/run init --non-interactive --force
$REPO/packages/eas-cli/bin/run integrations:convex:connect --non-interactive --project-name e2e3575fmt
```

Output/verification:
```text
yarn build exited 0
✅ Your project is ready!
✔ Created @fiberjw/eas-convex-e2e-3575-fmt
✔ Project successfully linked (ID: 56a35c21-e0a0-4423-a3bd-070cd5440370)
✔ Installed the convex npm package
✔ Created Convex team fiberjw's team (Expo) / fiberjw-1d252 (Convex ID: 478547)
- Setting up Convex project
Too many Convex project setup attempts. Try again later.
Request ID: 89cc010c-e894-49df-9214-85f60d7bb334
```

The project setup API is temporarily throttled for about an hour, so the full successful connect flow could not be rerun after the formatting change. The EAS-side team link created by this blocked run was removed afterward.
2026-05-01 18:07:44 +09:00
Juwan Wheatley e74c93d80c [eas-cli] Resolve GraphQL codegen URL from API config (#3636)
# Why

Addresses the review request from https://github.com/expo/eas-cli/pull/3575#discussion_r3041624004 to have GraphQL codegen use the same Expo API URL logic as the CLI instead of hardcoding or duplicating the staging GraphQL endpoint.

# How

Replaced `graphql-codegen.yml` with a typed `graphql-codegen.ts` config.

The config imports `getExpoApiBaseUrl()` from `src/api.ts` and computes the schema URL directly in the codegen config:

- `GRAPHQL_SCHEMA_URL=... yarn generate-graphql-code` still uses the explicit override
- `EXPO_LOCAL=1 yarn generate-graphql-code` defaults to `http://127.0.0.1:3000/graphql`
- `EXPO_STAGING=1 yarn generate-graphql-code` defaults to `https://staging-api.expo.dev/graphql`
- default codegen uses `https://api.expo.dev/graphql`

This removes the package-script shell substitution and the one-off resolver script.

# Test Plan

```bash
cd packages/eas-cli

yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# https://api.expo.dev/graphql

EXPO_STAGING=1 yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# https://staging-api.expo.dev/graphql

EXPO_LOCAL=1 yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# http://127.0.0.1:3000/graphql

GRAPHQL_SCHEMA_URL=http://example.test/graphql yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# http://example.test/graphql

EXPO_LOCAL=1 yarn generate-graphql-code
yarn build
```
I tested this locally with the local WWW server both alive and dead to make sure it was calling into the API:

<img width="1864" height="1878" alt="Screenshot 2026-04-28 at 01 05 33" src="https://github.com/user-attachments/assets/18e5d017-2c3b-4c45-8b78-b471bcef93e2" />
2026-05-01 05:51:01 +09:00
MalcolmandClaude Sonnet 4.6 3d691d602c [eas-cli] Fix invalid json for eas update with --json flag (#3659)
* [eas-cli] Pass silent:true to @expo/fingerprint in createFingerprintWithoutLoggingAsync

@expo/fingerprint spawns subprocesses concurrently (autolinking, react-native
config, ExpoConfigLoader) that can write to stdout, corrupting the parent
process's output when --json is in use. The silent option exists specifically
to suppress this, but was never set. The function is already named
WithoutLogging so this is the correct default regardless of --json.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* [eas-cli] Add changelog entry for fingerprint silent fix

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-30 11:46:25 -07:00
Stanisław Chmiela 7fde621858 [build-tools] Fix worker integration test mocks (#3662)
## Summary
- Mock `@expo/turtle-spawn` to surface the missing `expo/package.json` error path in the worker integration test
- Mock `turtleFetch` so the test can proceed without external fetch behavior
2026-04-30 18:39:45 +02:00
Ash Wu 91c9331e43 [eas-cli] remove image override in eas build:resign (#3661)
* [eas-cli] remove image override in eas build:resign

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

* [eas-cli] add changelog

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 22:57:39 +08:00
Stanisław Chmiela 02ba5880c4 [build-tools] Add expo package version to Datadog error logs (#3658)
## Summary
- Resolve the installed Expo package version in the worker when sending Turtle error logs.
- Tag Datadog error logs with `expo_package_version` while leaving `sdk_version` unchanged.
- Add unit coverage for exact-version resolution, missing package resolution, invalid semver, and the error-log payload.

## Testing
- `yarn workspace @expo/worker test:unit -- service.test.ts`
- `yarn workspace @expo/worker typecheck`
- `yarn exec oxfmt .`
2026-04-30 16:09:03 +02:00
Ash Wu 8229dc8265 [build-tools] Port Maestro Tests bash step to TypeScript (#3641)
* [build-tools] Port Maestro Tests bash step to TypeScript

Replace the bash shellscript step (defined in www/WorkflowMaestroTestJob)
with a typed BuildFunction. Behaviorally identical to the bash version:
runs maestro with the full flow_paths set, retries the whole set on
failure, copies the latest attempt's JUnit XML to final_report_path.

Smart retry (only re-running failed flows) is added in a follow-up PR.

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

* address comments

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

* address comments 2

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 17:47:35 +08:00
Stanisław Chmiela 15a456970b [build-tools] Scope precompiled modules env to pod install (#3646)
## Summary

- Stops adding precompiled modules environment variables to the whole worker build environment.
- Adds `EXPO_USE_PRECOMPILED_MODULES=1` only to the iOS `pod install` environment when requested and when the installed Expo package is at least `55.0.18`.
- Logs the detected Expo package version, the reason precompiled modules are not enabled, or the exact pod-install environment values that are added.
- Leaves the precompiled modules base URL helper commented out for the later third-party module rollout.

## Validation

- `yarn oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/build-tools jest-unit pod.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/worker typecheck`
2026-04-30 11:31:52 +02:00
Expo CI e74751c26c update CHANGELOG.md 2026-04-30 00:35:41 +00:00
Expo CI 0e2a1756ce v18.9.1 2026-04-30 00:33:23 +00:00
Brent Vatne 766e278aec [eas-cli] Alias --build-id to --id in build:download (#3656) 2026-04-29 17:29:41 -07:00
Expo CI 77e22c740a update CHANGELOG.md 2026-04-30 00:26:16 +00:00
Expo CI 8fdf47aa7d v18.9.0 2026-04-30 00:08:37 +00:00
Douglas Lowder 2279b650d5 [eas-cli] Allow eas build:download to accept a build ID (#3655)
# Why

For certain workflows, it is useful to be able to download build artifacts for a known build ID.

# How

Added a new flag `--build-id` to the `eas build:download` command. When this flag is used,`--fingerprint`, `--dev-client`, and `--platform` are excluded.

Also added the `--all-artifacts`flag. When set, all available artifacts are downloaded. Otherwise, the previous behavior applies (only application archive URL is downloaded).

# Test Plan

- Tested in a real project
- New unit tests added
2026-04-29 16:43:28 -07:00
Gabe Debes c6a36e2e47 [steps] Fix job context interpolation when one string has multiple ${{ }} blocks (#3644) 2026-04-29 15:53:34 -07:00
Szymon Świerk 27cb8c12d6 [eas-cli] Add support for configuring app store connect integration (#3558)
* ASC app link GQL queries

* ASC app link GQL mutations

* ASC utils

* status command

* disconnect command

* select or create ASC API key util function

* connect command

* add oclif connections topic

* gql codegen

* changelog

* regenerate readme

* dedicated app store api key purpose

* prompt user to choose the api key role

* remove unncecessary defensiveness in getting links remoteAppStoreConnectApp

* handle GQL error when invalid connection

* format

* rename connections to integations
2026-04-29 15:14:40 +02:00
Szymon Dziedzic 1122a7f374 [eas-cli] prompt users to stop device run session when they are done by running eas simulator:stop command (#3633) 2026-04-28 20:24:20 +02:00
Stanisław Chmiela c06d18138c [worker] Set precompiled modules base URL (#3642)
## Summary

Sets `EXPO_PRECOMPILED_MODULES_BASE_URL` for iOS worker builds that opt into precompiled modules with `EAS_USE_PRECOMPILED_MODULES=1`.

When the CocoaPods cache is configured, the URL is routed through that cache using the same host-prefix style as other cached downloads. When the cache is not configured, it points directly to `https://storage.googleapis.com/eas-build-precompiled-modules/`.

Local builds are unchanged and do not receive this env var.

## Test Plan

- `yarn oxfmt packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-04-28 19:33:42 +02:00
Szymon Dziedzic 3f4ff1dc3e [eas-cli] add eas simulator:stop command (#3626) 2026-04-27 14:11:13 +02:00
Mustafa Abban e852ab1cb7 [worker] - wrap error log fetch in try-catch to prevent unhandled rejections (#3631)
* [worker] - wrap error log fetch in try-catch to prevent unhandled rejections

* prevent real HTTP requests in integration tests
2026-04-24 12:28:11 -04:00
Mustafa Abban d941398ce6 [build-errors] - log all build errors with enriched metadata (#3612)
* enrich logs

* more metadata

* all error codes

* top level tags
2026-04-24 11:28:15 -04:00
Expo CI bae7684996 update CHANGELOG.md 2026-04-23 16:02:14 +00:00
Expo CI 714aec69c7 v18.8.1 2026-04-23 15:59:34 +00:00
Szymon Dziedzic bec6e670de [eas-cli] add eas simulator:start command (#3625) 2026-04-23 15:55:57 +00:00
Expo CI 900ad765bf update CHANGELOG.md 2026-04-22 17:53:23 +00:00
Expo CI 7c7fad4a13 v18.8.0 2026-04-22 17:50:27 +00:00
Douglas Lowder 7226c72122 [eas-cli] Add customParams support to observe:events (#3620)
# Why

Allow any custom params in Observe events to be retrieved as part of `observe:events` queries, and included in display and JSON output.

# How

Updated the event query and formatter.

# Test Plan

- Tested against projects in production
- Unit tests pass
2026-04-21 11:54:44 -07:00
Ash Wu ae6e0fd7f6 [build-tools] Reuse target_names to avoid duplicate iOS credentials preparation (#3611)
* [build-tools] Reuse target_names to avoid duplicate iOS credentials preparation

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

* clean up

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

* add test

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

* fix import order

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 22:32:01 +08:00
Kadi Kraman d070f73bc5 [eas-cli] add update and channel insights (#3614)
* Add error handler to prompt for update required

* Add an update:insights command

* Add an --insights flag to update:view

* Refactor shared util

* Improve naming

* Add a create table helper

* Pull date formating to a util

* Improve readability

* Validate insights params

* Add a test to ensure the table renders

* Add channel insights

* Fix lint

* Add PR to changelog
2026-04-21 10:30:29 +01:00
Ash Wu 325bb29b57 [build-tools] Add EAS_FALLBACK_PACKAGE_MANAGER env var support to package manager resolver (#3608)
* [build-tools][eas-build-job] Add workflow fallback package manager support to build-tools resolver

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 17:27:21 +08:00
Ash Wu 82a8e3c2d0 [build-tools] add parseXcactivitylogFunction (#3578)
* [build-tools] add parseXcactivitylogFunction

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

* address comments

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

* address comments

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

* clean up

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

* clean up

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

* clean up

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 17:00:22 +08:00
Gabe DebesandStanisław Chmiela ec310dd115 [build-tools] Introduce eas/deploy and eas/export functions (#3598)
* [build-tools] Add eas/deploy function

* format

* Update CHANGELOG.md

Co-authored-by: Stanisław Chmiela <[email protected]>

* Remove no-source-maps, set if not undefined

* [build-tools] Add eas/export and refine eas/deploy for EAS Hosting workflows

- Add eas/export: expo export via runExpoCliCommand and getExportCommand, structured inputs, export_dir output.
- Refine eas/deploy: build argv with getDeployCommand, parse deploy JSON with parseDeploymentOutput, expanded tests.
- Register eas/export in getEasFunctions; update CHANGELOG for both steps.

* build-tools: prefer easd in dev with probe and logging

Probe easd --help when ENVIRONMENT is development; use easd if it
succeeds, otherwise log and fall back to npx eas-cli@staging. Log
when easd is used. Pass logger from easBuildInternal into resolver
so messages appear in build logs.

* build-tools: warn when using easd in dev; no fallback log

Probe easd --help in development; on success log a warn and use easd.
Fall back to npx eas-cli@staging without an info line. Pass logger from
easBuildInternal and runEasCliCommand so the warn appears in logs.

* Fix parsing

* Improved error message

* self review

* Increase test coverage

* Update packages/build-tools/src/steps/functions/export.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/build-tools/src/steps/functions/deploy.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/build-tools/src/steps/functions/deploy.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* build-tools: align eas/deploy and eas/export with workflow env and Expo CLI

- deploy: stop passing --environment from job metadata; rely on workflow step env
- deploy: parseDeploymentOutput returns {} on failure; always set output providers
- deploy tests: assert env forwarding; drop --environment expectations
- export: use minify, dump_assetmap, ssg inputs (defaults match expo export)
- export: drop local platform allowlist; invalid values fail in expo export

* test(build-tools): cover deploy_url alias fallback without production

Assert deploy_url uses aliases[0].url when production is absent but aliases exist, so the middle tier of the fallback chain is exercised alongside the url-only partial JSON case.

Use camelCase for the dump_assetmap local in the export step (input id unchanged).

* Update default values and checks

* Remove logger from resolveEasCommandPrefixAndEnvAsync

* Fix CI

* revert uneeded change

* test(build-tools): restore ENVIRONMENT after runEasCliCommand tests

Mirror the resolveEasCommandPrefixAndEnvAsync suite so staging does not leak to later tests.

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-20 11:47:15 -07:00
Jakub Grzywacz 84b09c4a28 [eas-cli] Fix adding existing capability identifiers (#3615)
# Why

When an iOS app extension target declares App Group entitlements (e.g. `'com.apple.security.application-groups': ['group.com.easwidgets']`) and the app group already exists in the Apple Developer team but is not linked to the target's bundle identifier, credential setup enabled the App Groups capability on the bundle but never attached the group resulting with capability on but 0 groups linked.
```
Current local entitlements:
{
  "com.apple.security.application-groups": [
    "group.com.easwidgets"
  ]
}
Will enable remote capability: com.apple.security.application-groups (App Groups).
Existing to disable:  [ 'FJXGJ28CQS_IN_APP_PURCHASE' ]
Patch Request: [ { capabilityType: 'APP_GROUPS', option: 'ON' } ]
✔ Synced capabilities: Enabled: App Groups
- Syncing capabilities identifiers
No capability identifiers need to be updated
✔ Synced capability identifiers: No updates
```
This is a regression introduced in https://github.com/expo/eas-cli/pull/3088/
Fixes https://github.com/expo/expo/issues/43677

# How

Fetch the bundle via `BundleId.infoAsync` to determine which IDs are already linked to this bundle.
- If it exists and ID is already linked -> skip.
- If it exists but is not linked -> just link.
- If it doesn't exist -> create it, then link (unchanged behavior).

# Test Plan

After this changes:
```
Current local entitlements:
{
  "com.apple.security.application-groups": [
    "group.com.easwidgets"
  ]
}
Will enable remote capability: com.apple.security.application-groups (App Groups).
Existing to disable:  [ '6C659CPDFA_IN_APP_PURCHASE' ]
Patch Request: [ { capabilityType: 'APP_GROUPS', option: 'ON' } ]
✔ Synced capabilities: Enabled: App Groups
- Syncing capabilities identifiers
Updating bundle identifier with capability identifiers: [
  {
    "capabilityType": "APP_GROUPS",
    "option": "ON",
    "relationships": {
      "appGroups": [
        "NW5UULASH8"
      ]
    }
  }
]
✔ Synced capability identifiers: Linked: group.com.easwidgets
```
2026-04-20 11:05:33 +02:00
Evan BaconandClaude Opus 4.6 d9338799a0 [eas-cli] Fix metadata:push preview video deletion and improve validation (#3603)
- Delete remote previews when their type is removed from store config
- Warn and skip unknown preview types with APP_ prefix suggestion
- Clear all existing previews before upload to avoid Apple limits
- Fix nock mock type error for axios adapter

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-17 15:15:02 -07:00
Mustafa Abban b22d89e41f [build-tools] remove experimental prefix from gradle cache env var (#3613) 2026-04-16 15:34:28 -04:00
Szymon Świerk 81fe0f237f extend the app_store_connect.build_upload interpolation context (#3599) 2026-04-16 15:50:39 +02:00
Douglas Lowder b9ddc04cf7 [eas-cli] Improve observe:metrics handling of app version update IDs (#3609)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Currently the output of observe:metrics is unusable if an app version has many updates. Since the metrics are not broken out by update, it makes sense to make two changes:

- Remove update IDs from the display table
- Add the update IDs to the JSON output as an array, in case they are needed as input to other commands, or to AI

Additionally, made a change so that if a project ID is manually passed in, the observe commands do not unnecessarily try to create a new EAS project where none is needed.

# Test Plan

- Unit tests pass
- Tested manually against existing projects with metrics
2026-04-15 12:04:40 -07:00
Expo CI d6a8a4f694 update CHANGELOG.md 2026-04-14 18:51:07 +00:00
Expo CI 7f163e3e52 v18.7.0 2026-04-14 18:48:31 +00:00
Matheus Michels f821cb176c [eas-cli] Add --skip-bundler to eas build:dev (#3604)
* [eas-cli] Add skip-bundler to build:dev

* [eas-cli] Drop build:dev skip-bundler test

* [eas-cli] Tweak skip-bundler wording

* [eas-cli] Update changelog entry for skip-bundler

* [eas-cli] Drop README update for skip-bundler
2026-04-14 19:02:51 +01:00
Gabe Debes 3a70a5d9b8 [build-tools] Early stop eas/start_android_emulator when Android emulator host setup is invalid (#3580)
* [build-tools] early stop with improved error message on start android emulator when not on nested virtualization enabled host

* Add changelog entry

* Move towards cpuinfo approach

* Align var names

* fmt

* [build-tools] Use runtimePlatform for Android emulator virt checks

Align Android emulator nested-virtualization detection with step runtime metadata instead of Node process platform, and expand tests to assert early failure on non-Linux runtime platforms.

* [build-tools] Throw UserError for emulator virt precheck failure

Return a typed user-facing error when Android emulator virtualization requirements are not met so this early-stop path avoids noisy unknown-error stack output.

* [build-tools] move check towards begging of function

* Update error message

* Update CHANGELOG for recent build-tools changes

* Fix blank line on changelog

* tightening upon feedback, move to emulator accel check instead of cpuinfo

* Update CHANGELOG

* format fix

* test: restore startAndroidEmulator helper signature

Keep the startAndroidEmulator test helper aligned with main by removing the temporary runtime platform override from createStep.

* refactor build-tools emulator host support check naming

* format startAndroidEmulator test

* Update err code to align with rename

* Add docsUrl to user error

* Display inline link for now
2026-04-13 12:36:21 -07:00
Gabe Debes 4d74298bb7 [build-tools] add read app config and package.json functions (#3585)
add new eas/read_package_json and eas/read_app_config workflow functions
2026-04-10 14:58:32 -07:00
Expo CI 3155fc26e7 update CHANGELOG.md 2026-04-10 20:55:15 +00:00
Expo CI 0fb6dfc334 v18.6.0 2026-04-10 20:52:25 +00:00
Douglas Lowder dd84646768 [eas-cli] Add observe namespace and commands (#3564)
This is a merge of @Ubax 's changes from #3431 and #3432 , with refactoring to align with the recent OCLIF upgrade and some other adjustments:

- a new command `observe:versions` to query statistics for app versions after the www change in https://github.com/expo/universe/pull/26373
- changes to `observe:metrics` and `observe:events` to use the new app versions, following the www change in https://github.com/expo/universe/pull/26391
- commands are hidden for now

# Why

Provide CLI commands to display Observe metrics and events.

```
USAGE
  $ eas observe:metrics [--platform android|ios] [--metric tti|ttr|cold_launch|warm_launch|bundle_load...] [--stat
    min|median|max|average|p80|p90|p99|eventCount...] [--start <value> | --days <value>] [--end <value> | ]
    [--project-id <value>] [--json] [--non-interactive]

FLAGS
  --days=<value>        Show metrics from the last N days (mutually exclusive with --start/--end)
  --end=<value>         End of time range for metrics data (ISO date).
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --metric=<option>...  Metric name to display (can be specified multiple times).
                        <options: tti|ttr|cold_launch|warm_launch|bundle_load>
  --non-interactive     Run the command in non-interactive mode.
  --platform=<option>   Filter by platform
                        <options: android|ios>
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)
  --start=<value>       Start of time range for metrics data (ISO date).
  --stat=<option>...    Statistic to display per metric (can be specified multiple times)
                        <options: min|median|max|average|p80|p90|p99|eventCount>

DESCRIPTION
  display app performance metrics grouped by app version

---------------------------

USAGE
  $ eas observe:events [METRIC] [--sort slowest|fastest|newest|oldest] [--platform android|ios] [--after <value>]
    [--limit <value>] [--start <value> | --days <value>] [--end <value> | ] [--app-version <value>] [--update-id
    <value>] [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  [METRIC]  (tti|ttr|cold_launch|warm_launch|bundle_load) Metric to query (e.g. tti, cold_launch). Defaults to tti.

FLAGS
  --after=<value>        Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
  --app-version=<value>  Filter by app version
  --days=<value>         Show events from the last N days (mutually exclusive with --start/--end)
  --end=<value>          End of time range (ISO date)
  --json                 Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --limit=<value>        The number of items to fetch each query. Defaults to 10 and is capped at 100.
  --non-interactive      Run the command in non-interactive mode.
  --platform=<option>    Filter by platform
                         <options: android|ios>
  --project-id=<value>   EAS project ID (defaults to the project ID of the current directory)
  --sort=<option>        [default: oldest] Sort order for events
                         <options: slowest|fastest|newest|oldest>
  --start=<value>        Start of time range (ISO date)
  --update-id=<value>    Filter by EAS update ID

DESCRIPTION
  display individual app performance events ordered by metric value

DESCRIPTION
  display individual app performance events ordered by metric value

--------------------------------------

USAGE
  $ eas observe:versions [--platform android|ios] [--start <value> | --days <value>] [--end <value> | ] [--project-id
    <value>] [--json] [--non-interactive]

FLAGS
  --days=<value>        Show versions from the last N days (mutually exclusive with --start/--end)
  --end=<value>         End of time range (ISO date)
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --non-interactive     Run the command in non-interactive mode.
  --platform=<option>   Filter by platform
                        <options: android|ios>
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)
  --start=<value>       Start of time range (ISO date)

DESCRIPTION
  display app versions with build and update details
```

# How

New commands and GraphQL queries.

# Test Plan

New unit tests added in `fetchEvents-test.ts`
2026-04-10 13:41:04 -07:00
Phil Pluckthun c3fbf63a71 fix(eas-cli): Abort early on asset upload errors (#3600) 2026-04-10 19:28:36 +01:00
Szymon Dziedzic 7c0d7b60c7 [ENG-17052][build-tools] Add working_directory input to eas/build function group (#3582) 2026-04-10 11:58:56 +00:00
Szymon Dziedzic 5a8e1b4ba3 [ENG-14822][steps] Coerce env in steps to string (#3583) 2026-04-10 12:18:21 +02:00
Szymon Świerk 12b648a603 [eas-build-job] Add missing events support to app_store_connect workflows interpolation context (#3587)
* add missing events support to app store connect interpolation context

* tests
2026-04-10 10:35:03 +02:00
Evan BaconandClaude Opus 4.6 8c170af829 feat(metadata): add app clips support (#3590)
* Add App Clip metadata support

Introduce App Clip support across metadata pipeline: add JSON schema definitions for AppleAppClip and related types; extend Apple types with AppClip interfaces and action enum; add reader accessors (getAppClip*, getAppClipLocalizedInfo) and writer mutators (setAppClipDefaultExperience, setAppClipReviewDetail, setAppClipLocalizedInfo). Implement AppClipTask to prepare/download/upload App Clip default experience, localizations, header images and review invocation URLs, including helper functions for syncing and downloading header images. Wire the task into the tasks index, include AppClipData in AppleData, and add unit tests for reader, writer, and task behavior.

* Preserve placeholder assets and app clip fallback

Fix race/regression issues when App Store Connect records are not yet rendered: - ScreenshotsTask and PreviewsTask now preserve placeholder local paths when imageAsset/videoUrl is null (AWAITING_UPLOAD) so pulled config doesn't drop entries and users can recover by adding files or removing entries. - PreviewsTask/ScreenshotsTask use the downloaded path when available, otherwise build a deterministic store path (with filename or index-based fallback). - AppClipTask.downloadAsync now falls back to the template experience when the current version has no default experience.

Also adds/updates unit tests for app clip behaviors (fallback to template, preserving header image path, skipping no-op PATCHes and review detail updates, and aborting when no editable version), screenshots (placeholder paths and index-based filename fallback), and previews (preserving placeholder entries when videoUrl is null). These changes address intermittent failures and improve round-trip push/pull resilience.

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

Brings in the fix for image and video uploads via metadata:push. The
asset client was inheriting Bearer token injection from the App Store
Connect API client, which caused S3 presigned URL uploads to be silently
mishandled by Apple's CDN — leaving screenshots, previews, and App Clip
header images stuck in AWAITING_UPLOAD state.

Also adds new App Clip models (AppClipDefaultExperience,
AppClipDefaultExperienceLocalization, AppClipHeaderImage,
AppClipAppStoreReviewDetail) used by the new App Clip metadata support.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 12:31:22 -07:00
Byron Karlen ad4c5299e3 [auth] logout changes (#3555) 2026-04-09 08:44:14 -07:00
Evan BaconandClaude Opus 4.6 dee07ae0a2 [eas-cli] Bump @expo/apple-utils to 2.1.18 (#3588)
* Sync Apple metadata schema, docs, and flags

Add new Apple advisory fields to metadata-0.json (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) and an AppleAgeRatingOverrideV2 enum. Introduce CLAUDE.md documenting how to keep schema and reader/writer in sync. Replace manual 'non-interactive' flags in metadata push/pull with the shared EASNonInteractiveFlag. Add a unit test that validates AppleConfigWriter output against the JSON schema (imports validateConfig) as a drift guard to surface mismatches between writer output and the schema early. These changes prevent confusing validation errors when pulling/pushing metadata by keeping schema, docs, CLI flags, and tests aligned.

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

Fixes `eas metadata:push` failing in the age-rating phase with
"'gamblingAndContests' is not an attribute on the resource
'ageRatingDeclarations'". The published 2.1.17 bundle still emitted
that field at runtime even though its `.d.ts` typed it as `never`,
because @expo/apple-utils' prepare script regenerated types from source
but bundled @expo/app-store from a stale `build/` directory.
2.1.18 rebuilds @expo/app-store as part of the prepare flow.

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

* Fix changelog PR link

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 08:34:49 -07:00
Evan BaconandClaude Opus 4.6 4ad12340f2 Add new Apple metadata attributes (#3584)
* Add new Apple metadata attributes

Extend AppleConfigReader and AppleConfigWriter to include additional app rating/content fields (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) with sensible defaults (false/null or Rating.NONE). This ensures these attributes are preserved when reading and writing Apple metadata.

* update package

* Add missing fields to test fixtures and CHANGELOG entry

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 07:45:54 -07:00
c6d4faefe7 [build-tools][eas-cli] Support iOS Development provisioning profiles (#3496)
* [build-tools] Detect iOS Development provisioning profiles and set correct code signing identity

Add DEVELOPMENT distribution type to distinguish Development profiles
from Ad Hoc. Development profiles have get-task-allow=true in their
entitlements; Ad Hoc profiles don't.

When a Development profile is detected, override CODE_SIGN_IDENTITY to
"Apple Development" instead of the "iPhone Distribution" that
@expo/config-plugins hardcodes. This fixes builds that use
credentialsSource: "local" with Development profiles (e.g., for
Family Controls entitlement).

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

* [eas-cli] Accept Development provisioning profiles for internal distribution

Fix isAdHocProfile() to distinguish Development from Ad Hoc profiles by
checking the get-task-allow entitlement. Add isDevelopmentProfile() for
explicit detection.

Update assertProvisioningProfileType() to accept Development profiles
for internal distribution, and reject them for store distribution.

This enables credentialsSource: "local" with Development profiles needed
for entitlements like Family Controls (com.apple.developer.family-controls)
that only work with Development provisioning profiles.

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

* [build-tools] Use certificateCommonName for development code signing identity

Address review feedback: use the certificate common name from the
provisioning profile instead of hardcoding "Apple Development", which
doesn't cover all certificate types (e.g. "iPhone Developer", "iOS
Development"). Also fix grammar in error message ("an development" →
"a development").

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

* [build-tools] Add CHANGELOG entry for development provisioning profile fix

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

* [build-tools] Use upstream codeSignIdentity parameter instead of workaround

Now that @expo/[email protected] supports the optional
codeSignIdentity parameter in setProvisioningProfileForPbxproj,
pass profile.certificateCommonName directly for development profiles
and remove the overrideCodeSignIdentityForDevelopment workaround
that was reading/writing the pbxproj a second time.

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

* fix changelog

Temporary Commit at 4/2/2026, 1:09:37 PM

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Quinlan Jung <[email protected]>
2026-04-02 13:25:13 -07:00
Phil Pluckthun 2f8712a0f3 [build-tools] Differentiate between Yarn Classic/Modern based on lockfile contents (#3572)
* Detect Yarn legacy/classic based on lockfile contents

* Add CHANGELOG entry

* Fix typo

* Add tests
2026-04-02 21:11:11 +01:00
Expo CI a439721054 update CHANGELOG.md 2026-04-02 10:44:22 +00:00
Expo CI 2e2dddb59a v18.5.0 2026-04-02 10:41:22 +00:00
Szymon ŚwierkandStanisław Chmiela b7df3ff4f8 [eas-build-job] Add app_store_connect.build_upload workflow interpolation context (#3566)
* Add app_store_connect.build_upload workflow interpolation context

* limit the possible states

* make the id type uuid

* Update packages/eas-build-job/src/common.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/eas-build-job/src/common.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* remove no longer valid test

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-02 12:06:44 +02:00
Stanisław Chmiela 8a74758d56 [build-tools] Remove reuse_devices: false dead code (#3560)
## Summary
- remove the internal `eas/__maestro_test` function registration and implementation
- delete the Maestro flow/config discovery helper used by that internal path
- remove the related tests and fixture files for Maestro config parsing and flow resolution

## Testing
- `yarn --cwd packages/build-tools typecheck`
- `yarn --cwd packages/build-tools jest-unit --runInBand maestroResultParser reportMaestroTestResults easFunctions`
2026-04-02 10:38:59 +02:00
Szymon Świerk 26fdf8fb75 [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext (#3530)
* [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext

* app_store_connect.app, not app_store_connect.event.app
2026-04-01 11:10:48 +02:00
Stanisław Chmiela 9328063547 [build-tools] Remove precompiled modules preparation flow (#3565)
## Summary
- remove the worker and build-tools plumbing for precompiled dependency downloads and extraction
- stop waiting on precompiled dependency preparation before `pod install`
- delete the dedicated precompiled dependency helpers, fixtures, and tests
- keep the `EAS_USE_PRECOMPILED_MODULES=1` passthrough so iOS jobs still set `EXPO_USE_PRECOMPILED_MODULES=1`

## Testing

CI should pass.
2026-04-01 09:44:39 +02:00
Stanisław Chmiela fc66b8251e [eas-cli] Fix hasIgnoredIosProjectAsync (#3562)
## Why

`hasIgnoredIosProjectAsync()` currently always returns `false` because its `finally` block unconditionally returns `false`, overriding the value from the `try` block.

That means callers never see a positive ignored result even when the Xcode project is actually ignored.

## How

- replace the unconditional `finally` return with a `catch` fallback
- add a unit test that covers the ignored iOS project case

## Test Plan

CI should pass. Verified the test would fail if not for this change.
2026-04-01 09:25:07 +02:00
Stanisław Chmiela 394576368a [worker] Lower project dependencies telemetry errors to warning (#3535)
## Summary
- mark `FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT` as a Sentry warning instead of an error
- reduce noise from non-critical analytics failures in worker project dependency reporting
2026-03-31 10:30:17 +02:00
Evan BaconandClaude Opus 4.6 f80e34cffe feat(metadata): add screenshots and preview support (#3301)
* Add support for Apple screenshots and video previews

Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types.

* Update apple-utils and improve asset file handling

Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility.

* Use original type names for preview and screenshot dirs

Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming.

* Improve Apple metadata tasks error handling and schema

Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews.

* Use original filenames for Apple previews and screenshots

Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations.

* Update tests to skip on missing context instead of abort

Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands.

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

Allow nock mocks to match requests with query parameters in two tests. Added `.query(true)` to the GET interceptors in packages/eas-cli/src/metadata/apple/tasks/__tests__/previews.test.ts and packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts so the mocks won't fail when requests include query strings.

* [eas-cli] Fix age rating API and update @expo/apple-utils to 2.1.16

Move age rating from appStoreVersions to appInfos — Apple removed the
ageRatingDeclaration relationship from appStoreVersions. Also apply
formatting fixes from oxfmt.

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

* Update CHANGELOG.md

* [eas-cli] Improve screenshots/previews tasks: native fs, fileSize matching, parallel fetching

- Replace fs-extra with native fs/fs.promises
- Add fileSize check alongside filename matching to detect changed files
- Parallelize locale fetching in prepareAsync with Promise.all
- Skip reorder API call when screenshot order is unchanged
- Remove unnecessary runtime method check for getAppPreviewSetsAsync
- Use consistent index-based fallback filenames for previews

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 17:46:55 -07:00
Quinlan Jung 🦥 59ad2c2ca0 Reapply "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534) (#3543)
This reverts commit 92ad6b5ba3.
2026-03-30 14:04:44 -07:00
Evan BaconandClaude Opus 4.6 4e95a29797 Support non-interactive metadata commands with ASC API key auth (#3548)
* Support non-interactive metadata commands

Add a --non-interactive flag to metadata pull/push and thread nonInteractive through context and helper calls. Propagate the flag to ensureProjectConfiguredAsync, getProfilesAsync, getAppStoreAuthAsync, downloadMetadataAsync and uploadMetadataAsync so prompts are skipped or fail fast when non-interactive.

Improve App Store Connect auth resolution: try ASC API key from submit profile, then EAS credentials service (via GraphQL lookup), then environment variables; fall back to interactive cookie auth only when allowed. In non-interactive mode, throw a clear error if no ASC API key is available. Also update upload/download to auto-overwrite in non-interactive mode and make config validation in upload fail instead of prompting.

Minor: import/fs usage and GraphQL client types added, small logging improvements and error messages to aid non-interactive workflows.

* Add metadata tests and enhance App Store auth

Add unit tests for metadata auth, download, and upload flows and update App Store auth resolution to include team metadata.

- New tests: src/metadata/__tests__/auth.test.ts, download.test.ts, upload.test.ts covering API key resolution, interactive vs non-interactive behavior, telemetry, and validation prompts.
- auth.ts: refactor tryResolveAscApiKeyAsync to return ascApiKey plus optional teamId/teamName, read profile-provided keyP8 and prefer profile keys over stored keys, and include teamId/teamName/teamType (defaulting to COMPANY_OR_ORGANIZATION) when calling ensureAuthenticatedAsync.

These changes improve handling of ASC API keys and team info during authentication and add coverage for metadata CLI flows and edge cases.

* Use fs.promises.readFile instead of fs-extra

Replace import of fs-extra with Node's built-in fs and call fs.promises.readFile when loading the ASC API key. Update tests to mock fs.promises.readFile accordingly. This removes the dependency on fs-extra for this code path and aligns the readFile usage with the fs.promises API.

* [eas-cli] Add CHANGELOG entry for metadata non-interactive support

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 11:09:41 -07:00
Stanisław Chmiela 66d8e3d6b8 Add test precompiled iOS dependency preparation support (#3556)
## Summary
- export precompiled modules helpers from `@expo/build-tools` and start preparation when flagged builds enable precompiled modules
- wait for precompiled module preparation before running `pod install` in both build-tools pod flows
- set worker env and config for precompiled module downloads, including proxy-aware download handling and coverage for the new behavior

## Testing
<img width="812" height="325" alt="Zrzut ekranu 2026-03-28 o 22 26 35" src="https://github.com/user-attachments/assets/9e4deb10-d233-4bb6-ac5d-48cef31c17ed" />

<img width="1032" height="548" alt="Zrzut ekranu 2026-03-28 o 22 27 27" src="https://github.com/user-attachments/assets/a6bbcb75-4845-4dce-9f31-5097ece800ae" />
2026-03-30 19:37:41 +02:00
Stanisław Chmiela 5e4a88094f [build-tools] Disable Android emulator window transitions (#3557)
## Summary
- disable Android emulator window and transition animation scales once the emulator is booted and ready
- apply the setting for the initial emulator and any cloned emulator instances used by the start step
- add unit coverage for the new adb settings commands and the startup orchestration

## Why
Maestro test runs already avoid the emulator boot animation, but Android window and transition animations still run after boot. Those system animations add avoidable UI transition latency and can introduce test flake without affecting in-app animator timing.

See https://bsky.app/profile/rdperottoni.bsky.social/post/3mfnhnt77mk2f.

## Validation
- `yarn fmt packages/build-tools/src/utils/AndroidEmulatorUtils.ts packages/build-tools/src/steps/functions/startAndroidEmulator.ts packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts`
- `yarn --cwd packages/build-tools jest-unit -- runInBand src/steps/functions/__tests__/startAndroidEmulator.test.ts src/utils/__tests__/AndroidEmulatorUtils.test.ts`
2026-03-30 19:04:46 +02:00
Kudo Chien 7a4c81f4e3 [build-tools] resolve repack version (#3549) 2026-03-28 01:48:50 +08:00
Mustafa Abban c9cdfd1eb5 [build-tools] support Gradle build cache for Android builds (#3510)
* gradle cache

* cache modules-2

* cache modules-2

* proper path

* set properties, rename

* rem modules-2

* set org.gradle.cache.cleanup to ALWAYS

* set org.gradle.cache.cleanup to ALWAYS

* consolidate caches in restore/save step

* restore gradle

* cleanup nullthrows

* always set properties if env set, cleanup

* consistent path, clean log

* modify gradle props in trycatch

* eas-cache-cleanup script

* shorter prune
2026-03-26 12:34:18 -05:00
Quinlan Jung 🦥 8115dc8766 [eas-cli] bump apple-utils (#3545)
* [eas-cli] bump apple-utils

* Temporary Commit at 3/25/2026, 6:42:37 PM

* Temporary Commit at 3/25/2026, 7:18:01 PM
2026-03-25 19:27:14 -07:00
Byron Karlen 113467c91d Reapply "Use auth code flow (#3398)" (#3422) (#3542)
This reverts commit 0fdf2dd44a.
2026-03-25 12:32:58 -07:00
Phil Pluckthun be967f4b9a [build-tools] Read app config with expo config CLI invocation before falling back to @expo/config (#3536)
* Add `expo config` app config reading to build-tools

* Align with other CLI invocations

* Switch to turtle-spawn for consistency

* Update `appConfig` reading for async calls

* Add CHANGELOG entry

* Update worker

* Fix typo

* Add unit tests
2026-03-25 15:13:48 +00:00
Stanisław Chmiela 660303b0c3 [steps] Use counter-based generated step ids (#3541)
## Summary
- replace UUID-based autogenerated step ids with a counter in `BuildStep.getNewId()`
- keep explicit user-defined ids unchanged
- rename the step-id test helper so tests no longer talk about UUIDs

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildConfigParser-test.ts StepsConfigParser-test.ts AbstractConfigParser-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 15:02:30 +01:00
Stanisław Chmiela 5514289648 [steps][local-build-plugin] Remove buildStepInternalId (#3540)
## Summary
- remove `buildStepInternalId` emission from `BuildStep`
- stop generating the extra UUID entirely
- drop the now-unused field from local build log sanitization

Just want to make logs smaller.

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:36:12 +00:00
Stanisław Chmiela 7924e33e96 [eas-cli] Key workflow logs by buildStepId (#3539)
## Summary
- key workflow log groups by `buildStepId` instead of display text (this might cause two steps named the same to appear as one)
- treat `buildStepInternalId` as nonexistent in the CLI parser (want to remove it)
- keep display names for prompts and status output while preserving stable selection keys

## Testing
- yarn --cwd packages/eas-cli test src/__tests__/commands/workflow-logs-test.ts src/commandUtils/workflow/__tests__/utils-test.ts
- yarn --cwd packages/eas-cli typecheck
2026-03-25 14:31:11 +01:00
Stanisław Chmiela e699a06c07 [steps] Simplify name/displayName handling (#3538)
## Summary
- remove `BuildStep.getDisplayName` and inline display-name selection at the shell/function callsites
- simplify shell step naming to `name ?? first non-empty command line ?? command`
- update step tests to use explicit display names instead of the removed helper

## Verification
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildWorkflowValidator-test.ts BuildStepOutput-test.ts BuildStepInput-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:42:41 +01:00
Stanisław Chmiela ce819c1a14 [eas-cli] Add optional Sentry error reporting (#3537)
## Summary
- add a dedicated `packages/eas-cli/src/sentry.ts` module that initializes `@sentry/node` for eas-cli
- capture command failures from `EasCommand`, including `EasCommandError`, and flush Sentry on exit
- propagate `EAS_CLI_SENTRY_DSN` into the worker child-process environment so worker-run eas-cli commands can report errors too

https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1773432161848589?thread_ts=1773417343.042269&cid=C06EFBQK3B7

## Validation
- `yarn test src/commandUtils/__tests__/EasCommand-test.ts --runInBand`
- `yarn test:unit --runInBand src/__unit__/env.test.ts`
- `yarn build` in `packages/eas-cli`
- `node ./bin/run --version`
- `node ./bin/run --help`
- `node ./bin/run analytics`
- `EAS_CLI_SENTRY_DSN=https://[email protected]/1 node ./bin/run analytics`

## Manual Sentry Verification
- ran the compiled CLI against the test Sentry project `expo-stanley-test/test-local` with a real DSN
- forced an `EasCommandError` from `eas analytics` and confirmed the CLI failed locally as expected
- verified that Sentry ingested the event and created issue `TEST-LOCAL-1`
- confirmed the event in Sentry at `https://expo-stanley-test.sentry.io/issues/TEST-LOCAL-1`
2026-03-25 13:37:09 +01:00
Quinlan Jung 🦥 92ad6b5ba3 Revert "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534)
This reverts commit d63d51f548.
2026-03-24 17:44:58 -07:00
Quinlan Jung 🦥 d63d51f548 [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)
* [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins

* Temporary Commit at 3/23/2026, 5:55:24 PM

* Temporary Commit at 3/23/2026, 6:10:13 PM
2026-03-24 11:56:19 -07:00
Stanisław Chmiela bfb12f0317 [repo] Standardize clean scripts on rimraf (#3520)
## Summary
- standardize workspace clean/rebuild/prepack deletion steps on `rimraf`
- add `rimraf` where package-local scripts invoke it directly
- keep `worker` and root clean flows working from a clean install

Also adds Codex config for new workspace.

And the problem it solves is that sometimes `yarn clean` would fail if there were no `*.tsbuildinfo` files. Something weird about zsh in package.json

## Validation
- mise exec -- yarn clean
- mise exec -- yarn install --immutable
- mise exec -- yarn build
- mise exec -- yarn typecheck
- mise exec -- yarn clean (in packages/worker)
- mise exec -- yarn clean (in packages/logger)
2026-03-23 17:33:02 +01:00
Douglas LowderandClaude Opus 4.6 b162162007 [eas-cli] Fix workflow:logs for builds using built-in EAS build steps (#3523)
# Why

Logs from EAS build jobs using the built-in flow are in a different form than those from build jobs using custom YAML. Because of this, `eas workflow:logs` and `eas workflow:status` were not working correctly for workflow runs with these jobs.

# How

Analyze the differences in format, and modify the utils that parse logs to handle both formats.

# Test Plan

- Tested with recent workflow runs from expo/expo
- Unit tests updated

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 08:11:33 +01:00
Stanisław Chmiela 6ed7e14a69 [eas-cli] Update code for the new PartnerActor (#3521)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

After new union type addition GQL fails TSC in `eas-cli`.

# How

Added fetching of `username` for `PartnerActor` and used it where we would previously use `username` of a `User`.

Removed `getUsernameForBuildMetadataAndBuildJob` in favor of `getActorUsername`, I don't think these will every be different?

Removed 

# Test Plan

CI should pass.
2026-03-19 15:31:43 +01:00
Kudo Chien 37910b17e1 [build-tools] add js_bundle_only to repack function (#3517)
# Why

close ENG-17737

# How

adding `js_bundle_only` support to repack
2026-03-19 20:01:06 +08:00
Stanisław Chmiela 0d3c56f4d6 [build-tools] Improve ASC app ID error guidance (#3512)
## Summary
- surface example visible App Store Connect apps for invalid bundle-id upload failures
- reuse shared ASC app summary formatting for not-found and invalid-bundle-id errors
- add unit coverage for visible app summaries and message contents

Should help debug https://discord.com/channels/695411232856997968/1483454279204343910/1483492265312780340

## Testing
- ./node_modules/.bin/jest --config packages/build-tools/jest/unit-config.ts packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts
- ./node_modules/.bin/tsc -p packages/build-tools/tsconfig.json --noEmit
2026-03-17 16:59:49 +01:00
Stanisław Chmiela bc8e60d385 [build-tools] Handle ASC SDK version issue (90725) (#3464)
## Why
UNKNOWN submit failures include App Store Connect SDK compatibility error `90725`.

This should be surfaced as actionable EAS guidance (rebuild with newer iOS SDK/Xcode image) instead of generic unknown.

## How
- add `90725` detection in `upload_to_asc` failed-state handling
- throw `EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE` with EAS-specific remediation
- include docs URL to iOS build image/Xcode configuration docs
- add unit tests for `isSdkVersionIssueError`

## Examples
- https://expo.dev/uuid/019cbbe7-9d71-7c90-80b5-a98870478265
- https://expo.dev/uuid/019cbf52-3cbd-7491-b35e-c3fc2f24afc9
- https://expo.dev/uuid/019cbf4f-41d9-7ab7-8e85-e53596526a82

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-17 16:35:00 +01:00
Expo CI 99b8d67d3f update CHANGELOG.md 2026-03-16 20:24:39 +00:00
Expo CI 4e202db843 v18.4.0 2026-03-16 20:21:54 +00:00
Stanisław Chmiela ab209463ab [worker] Prevent forwarding HTTP log failures to GCS logstream (#3507)
Makes sure we don't log "Failed to upload batch of logs" to user-facing log as we did [here](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cf7b3-19d6-7b57-bb6f-41d5c63491e3).
2026-03-16 19:02:25 +01:00
Stanisław Chmiela 99e9d367a1 [worker] Add ITSAppUsesNonExemptEncryption flag to worker app config (#3506)
Summary
- add the missing `ITSAppUsesNonExemptEncryption` boolean to `packages/worker/app.json` so the worker app can declare non-exempt encryption usage and avoid iOS config errors

Should fix system tests!
2026-03-16 18:23:50 +01:00
Stanisław Chmiela a6f68462c9 Enable staging HTTP log streaming (#3505)
## Summary
- enable worker HTTP log streaming for staging using https://staging-logs.expo.dev/logs/


This is not tied to anything in UI yet. Just want to see the logs flowing.
2026-03-16 16:41:07 +01:00
Szymon ŚwierkandStanisław Chmiela 1c58823991 [eas cli] Builds: wait before showing the concurrency limit message (#3493)
* [eas cli] builds: wait before showing the concurrency limit message

* Update packages/eas-cli/src/build/build.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* apply style/naming CR suggestions

* remove the unnecessarily defensive clean-ups

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-03-16 15:11:46 +01:00
Ash Wu 6a804ab8d7 [build-tools] Store per-attempt results for Maestro test retries (#3498)
Signed-off-by: Ash Wu <[email protected]>
2026-03-16 21:01:42 +08:00
Stanisław Chmiela 74eff9bf67 [worker] Stop mutating errors in Sentry reporting (#3502)
## Summary
- stop mutating the original error instance in worker Sentry reporting
- attach the worker message to Sentry as an extra instead of rewriting the exception message — matches `universe/server-logging`
- keep the launcher-facing error payload unchanged
- fixes integration tests

## Testing
- yarn test:integration:watch stateSync
- yarn test:unit sentry
2026-03-13 12:42:54 +01:00
Stanisław Chmiela d20c465ddf [eas-build-job] Introduce SystemError and replace BuildError (#3471)
## Summary
- introduce `SystemError` as the explicit system-failure type
- replace in-repo `BuildError` usage with `ExpoError`, `UserError`, and `SystemError`
- keep unknown errors on the user-error side

## Changes
- remove the old `BuildError` class from the in-repo error flow
- make `SystemError` always publish `SERVER_ERROR` while keeping `trackingCode` for internal use
- update worker handling, analytics, and state sync to use the shared `ExpoError` contract
- avoid mutating the user-facing error object during Sentry reporting

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn build && npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-13 09:20:09 +01:00
Evan Bacon 0c17846e67 Auto-detect non-interactive mode in CI / non-TTY environments (#3486)
# Why

`eas build -p android` prompts for keystore generation and fails in CI or non-TTY environments:

```
Input is required, but stdin is not readable. Failed to display prompt: Generate a new Android Keystore?
```

Passing `--non-interactive` works around this, but it should be auto-detected when `CI=1` or stdin is not a TTY.

# How

Set a `default` function on both `--non-interactive` flag definitions (`EasNonInteractiveAndJsonFlags` and `EASNonInteractiveFlag`) in `packages/eas-cli/src/commandUtils/flags.ts`. The default checks `CI` env var (via `boolish` from `getenv`) and `process.stdin.isTTY`.

- oclif evaluates `default` only when the user does NOT pass `--non-interactive` on CLI
- `noCacheDefault: true` prevents baking the developer's environment into `oclif.manifest.json`
- All ~60 commands using these flag definitions automatically get CI/TTY auto-detection with no other changes needed

# Test Plan

- Added 5 unit tests for `isNonInteractiveByDefault()` covering: TTY without CI (false), CI=1 (true), CI=true (true), non-TTY stdin (true), both CI and non-TTY (true)
- All existing `resolveNonInteractiveAndJsonFlags` tests pass unchanged
- `yarn typecheck` passes
- `yarn lint` passes (0 errors)

```
PASS src/commandUtils/__tests__/flags-test.ts
  isNonInteractiveByDefault
    ✓ returns false when stdin is a TTY and CI is not set
    ✓ returns true when CI=1
    ✓ returns true when CI=true
    ✓ returns true when stdin is not a TTY
    ✓ returns true when both CI is set and stdin is not a TTY
  resolveNonInteractiveAndJsonFlags
    ✓ returns both false when no flags are set
    ✓ --json implies --non-interactive
    ✓ --non-interactive alone does not enable json
    ✓ both flags explicitly set
    ✓ both flags explicitly false
    ✓ --json false with --non-interactive true

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-12 12:29:04 -07:00
Douglas Lowder d5a3fc06f9 Enable manual testing of changelog GitHub workflow (#3500)
# Why

There have been failures recently with the GitHub CHANGELOG check. Adjusting the workflow so that it can be manually tested.

# How

Add `workflow_dispatch:`

# Test Plan

CI should pass
2026-03-12 10:57:58 -07:00
Matheus MichelsandDouglas Lowder c51ac27e25 [eas-cli] add skip-build-if-not-found option to eas build:dev (#3495)
* add skip-build-if-not-found to build:dev

* add changelog entry for build:dev skip flag

* Remove duplicate line in CHANGELOG

* Adjust CHANGELOG

* Bump add-pr-comment workflow dep

---------

Co-authored-by: Douglas Lowder <[email protected]>
2026-03-11 17:57:44 -07:00
467 changed files with 55261 additions and 12667 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
# upstream security patches because tag movements no longer reach us.
- package-ecosystem: github-actions
directories:
- /
- /.github/actions/setup-mise
- /.github/internal-actions/setup-gcloud
- /.github/internal-actions/notify-slack-on-fail-or-recover
schedule:
interval: weekly
groups:
actions:
patterns:
- '*'
@@ -12,7 +12,7 @@ runs:
using: 'composite'
steps:
- name: Get previous workflow run status
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
id: run-status
with:
script: |
@@ -28,7 +28,7 @@ runs:
- name: Send Slack Success Notification
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: good
@@ -41,7 +41,7 @@ runs:
- name: Send Slack Failure Notification
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: danger
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@v2
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
@@ -26,11 +26,11 @@ jobs:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@v2.1.0
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: always()
with:
message-id: changelog-entry-check
+7 -1
View File
@@ -1,5 +1,8 @@
name: codemention
on:
# pull_request_target is required so fork PRs get a write token to post the
# mention comment. Safe because there is no checkout — the action only reads
# PR metadata via the GitHub API; no fork code is fetched or executed.
pull_request_target:
types: [opened, synchronize, ready_for_review]
jobs:
@@ -9,6 +12,9 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: tobyhs/[email protected]
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag from
# a compromised upstream account would give the attacker repo write access.
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -81,7 +81,7 @@ jobs:
runs-on: ubuntu-20.04
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -126,7 +126,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@v2
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@v1
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -29,7 +29,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --immutable
@@ -58,7 +58,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
@@ -91,7 +91,7 @@ jobs:
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v4
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
with:
ascending: false
operations-per-run: 300
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
@@ -66,7 +66,7 @@ jobs:
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@v1
- uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192 # v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -79,7 +79,7 @@ jobs:
name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
+1
View File
@@ -16,6 +16,7 @@
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
+7
View File
@@ -1,2 +1,9 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
+1
View File
@@ -3,3 +3,4 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+231 -2
View File
@@ -8,12 +8,242 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `--skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
### 🐛 Bug fixes
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
### 🎉 New features
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
### 🎉 New features
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
@@ -1675,7 +1905,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.3.0",
"version": "20.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -1
View File
@@ -17,7 +17,7 @@
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
@@ -30,6 +30,7 @@
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
+15 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.2.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -24,32 +24,34 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.2.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/logger": "20.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.2.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
@@ -75,7 +77,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.2.5",
"@expo/repack-app": "~0.4.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -91,6 +93,7 @@
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
@@ -1,10 +1,7 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
module.exports = { ...fs.promises, rm: fsRm };
@@ -1,14 +1,36 @@
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -116,4 +138,68 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -0,0 +1,383 @@
import { Response } from 'node-fetch';
import { Datadog } from '../datadog';
import { Sentry } from '../sentry';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
const sentryCaptureMock = jest.mocked(Sentry.capture);
describe('Datadog singleton', () => {
beforeEach(() => {
jest.clearAllMocks();
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.maestro_cli_version',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
{
json: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
},
})
);
});
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{ name: 'eas.workflow.maestro_cli_version', type: 'distribution', value: 1, tags: {} },
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build log',
expect.any(Error),
expect.objectContaining({
extras: {
log: { buildId: 'build-id', message: 'artifact dry-run matched', tags: {} },
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: { jobRunId: 'job-run-id', message: 'Gradle cache restored (hit)', tags: {} },
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.distribution('eas.build.phase_duration', 1);
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('flushes pending log uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.log('artifact dry-run matched');
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -0,0 +1,256 @@
import * as sentryNode from '@sentry/node';
import { Sentry } from '../sentry';
jest.mock('@sentry/node');
describe('Sentry singleton', () => {
beforeEach(() => {
jest.mocked(sentryNode.init).mockReset();
jest.mocked(sentryNode.flush).mockReset();
jest.mocked(sentryNode.captureException).mockReset();
jest.mocked(sentryNode.captureMessage).mockReset();
jest.mocked(sentryNode.withScope).mockReset();
});
describe('setup', () => {
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
});
});
it('passes default tags into the SDK init as initialScope', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'production',
tags: { service: 'worker:darwin' },
});
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
initialScope: { tags: { service: 'worker:darwin' } },
});
});
it('skips SDK init when dsn is null', () => {
Sentry.setup({ dsn: null, environment: 'development' });
expect(sentryNode.init).not.toHaveBeenCalled();
});
it('passes the latest initialScope tags when setup is called more than once', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'first' },
});
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'second' },
});
expect(sentryNode.init).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
);
});
});
describe('capture', () => {
it('routes (msg) to captureMessage with msg only', () => {
const scope = mockWithScope();
Sentry.capture('plain message');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
expect(sentryNode.captureException).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
});
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
const scope = mockWithScope();
Sentry.capture('something happened', {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
level: 'warning',
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture('something went wrong', err);
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
const scope = mockWithScope();
const err = new Error('spawn ENOENT');
Sentry.capture('xclogparser failed', err, {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('does not set the `message` extra when msg equals err.message', () => {
const scope = mockWithScope();
const err = new Error('build failed');
Sentry.capture('build failed', err);
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('routes (err) alone to captureException with no scope mutations', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err);
expect(scope.setTags).not.toHaveBeenCalled();
expect(scope.setExtras).not.toHaveBeenCalled();
expect(scope.setLevel).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null', null as unknown as Error);
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, null, options) as a no-err call', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null with opts', null as unknown as Error, {
tags: { phase: 'cleanup' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded with no err', undefined, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
mockWithScope();
Sentry.capture('caught a string', 'boom' as unknown as Error);
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('boom');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
const scope = mockWithScope();
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('raw string thrown');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (err, options) to captureException with no message extra', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
});
describe('flush', () => {
it('forwards the timeout to the SDK and resolves with its result', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
const result = await Sentry.flush(3000);
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
expect(result).toBe(true);
});
it('defaults the timeout to 2000ms when not specified', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
await Sentry.flush();
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
});
});
});
function mockWithScope(): {
setTags: jest.Mock;
setExtras: jest.Mock;
setExtra: jest.Mock;
setLevel: jest.Mock;
} {
const scope = {
setTags: jest.fn(),
setExtras: jest.fn(),
setExtra: jest.fn(),
setLevel: jest.fn(),
};
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
cb(scope as unknown as sentryNode.Scope);
return undefined as any;
});
return scope;
}
+21 -12
View File
@@ -32,18 +32,27 @@ export async function runGradleCommand(
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -0,0 +1,231 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -67,28 +67,26 @@ export async function resolveBuildPhaseErrorAsync(
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.BuildError> {
): Promise<errors.ExpoError> {
const { phase } = errorContext;
if (error instanceof errors.BuildError) {
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
error instanceof errors.UserError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.BuildError(userError.message, {
errorCode: userError.errorCode,
return new errors.UserError(userError.errorCode, userError.message, {
trackingCode,
docsUrl: userError.docsUrl,
cause: error,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
@@ -4,7 +4,9 @@ import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -56,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -67,8 +72,11 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
@@ -138,7 +146,6 @@ describe(androidBuilder, () => {
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
@@ -157,23 +164,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
@@ -182,23 +189,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
@@ -214,7 +221,6 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
@@ -234,15 +240,19 @@ describe(androidBuilder, () => {
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
@@ -263,4 +273,37 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
+42 -3
View File
@@ -10,12 +10,18 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
@@ -26,6 +32,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -81,6 +88,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -93,7 +106,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -170,6 +183,20 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -182,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -196,6 +229,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
+36 -3
View File
@@ -16,6 +16,8 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -23,6 +25,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -51,6 +54,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -103,7 +107,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -146,10 +150,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
await runFastlaneGym(ctx, {
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -171,6 +175,29 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds skip build performance analysis.');
return;
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
const { skipped } = await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -183,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -0,0 +1,199 @@
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -1,8 +1,9 @@
import {
BuildJob,
EasCliNpmTags,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
@@ -14,7 +15,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { isAtLeastNpm7Async } from '../utils/packageManager';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -49,38 +50,48 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
try {
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
} catch (err: any) {
throw new Error('Failed to run eas build:internal');
}
}
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
@@ -121,33 +132,6 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
@@ -168,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
+6 -8
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
@@ -113,7 +110,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = ctx.appConfig;
const appConfig = await ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
@@ -121,12 +118,13 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
throw new Error(
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
);
+15 -10
View File
@@ -2,7 +2,6 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -20,6 +19,7 @@ import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -64,7 +64,6 @@ export interface BuildContextOptions {
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -96,8 +95,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: ExpoConfig;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
private _appConfig?: Promise<ExpoConfig>;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -113,7 +111,6 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -173,16 +170,18 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
}
public get appConfig(): ExpoConfig {
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).exp;
}).then(config => config.exp);
}
return this._appConfig;
}
@@ -287,7 +286,7 @@ export class BuildContext<TJob extends Job = Job> {
private async handleBuildPhaseErrorAsync(
err: any,
buildPhase: BuildPhase
): Promise<errors.BuildError> {
): Promise<errors.ExpoError> {
const buildError = await resolveBuildPhaseErrorAsync(
err,
this.logBuffer.getPhaseLogs(buildPhase),
@@ -352,7 +351,13 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
if (!doNotMarkEnd) {
this.logger.info(
+89
View File
@@ -0,0 +1,89 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags }
: { jobRunId: target.turtleJobRunId, message, tags };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
+6
View File
@@ -20,4 +20,10 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
@@ -9,6 +9,7 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -22,6 +23,7 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -39,6 +41,7 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -53,6 +56,7 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -67,6 +71,7 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -81,6 +86,7 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -22,6 +22,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -45,6 +49,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -70,6 +78,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -97,6 +109,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
'Info.plist application target'
);
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
@@ -0,0 +1,128 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,6 +58,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -94,6 +96,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -131,6 +135,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -192,6 +198,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -210,6 +218,7 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -226,6 +235,7 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -243,6 +253,7 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
@@ -0,0 +1,203 @@
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
expoUsePrecompiledModules,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
expoUsePrecompiledModules?: string;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(expoUsePrecompiledModules
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
: {}),
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
});
}
describe(installPods, () => {
beforeEach(() => {
vol.reset();
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
await installPods(ctx, {});
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
});
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
)
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: true,
expoUsePrecompiledModules: '0',
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
);
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
import { BuildContext } from '../context';
async function configureXcodeProject(
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
}
);
}
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -137,6 +138,10 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
+16 -2
View File
@@ -3,6 +3,7 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<void> {
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
cwd: workspacePath,
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -122,6 +131,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
+9
View File
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -59,6 +64,8 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -86,6 +94,7 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
+77 -1
View File
@@ -1,9 +1,14 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -35,3 +42,72 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
return {};
}
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
return PRECOMPILED_MODULES_BASE_URL;
}
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
+81
View File
@@ -0,0 +1,81 @@
import * as sentryNode from '@sentry/node';
type CaptureOptions = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
level?: sentryNode.SeverityLevel;
};
type SentryAPI = {
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
capture(msg: string, options?: CaptureOptions): void;
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
capture(err: Error, options?: CaptureOptions): void;
flush(timeoutMs?: number): Promise<boolean>;
};
export const Sentry: SentryAPI = {
setup({ dsn, environment, tags }) {
if (dsn) {
sentryNode.init({
dsn,
environment,
...(tags ? { initialScope: { tags } } : {}),
});
}
},
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
let msg: string | undefined;
let err: Error | undefined;
let options: CaptureOptions = {};
if (arg1 instanceof Error) {
err = arg1;
options = (arg2 as CaptureOptions | undefined) ?? {};
} else {
msg = arg1;
if (arg3 !== undefined) {
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
options = arg3;
if (arg2 !== undefined && arg2 !== null) {
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
}
} else if (arg2 instanceof Error) {
err = arg2;
} else if (arg2 !== undefined && arg2 !== null) {
// 2-arg form: non-Error object → options; primitive → coerced err
if (typeof arg2 === 'object') {
options = arg2;
} else {
err = new Error(String(arg2));
}
}
}
sentryNode.withScope(scope => {
if (options.tags) {
scope.setTags(options.tags);
}
if (options.extras) {
scope.setExtras(options.extras);
}
if (options.level) {
scope.setLevel(options.level);
}
if (err) {
if (msg && err.message !== msg) {
scope.setExtra('message', msg);
}
sentryNode.captureException(err);
} else if (msg) {
sentryNode.captureMessage(msg);
}
});
},
flush(timeoutMs: number = 2000): Promise<boolean> {
return sentryNode.flush(timeoutMs);
},
};
@@ -0,0 +1,27 @@
import { getEasFunctionGroups } from '../easFunctionGroups';
describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
const ctx = {
hasBuildJob: () => false,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(['eas/maestro_test']);
});
it('includes eas/build for build jobs', () => {
const ctx = {
hasBuildJob: () => true,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});
});
+18 -3
View File
@@ -9,6 +9,8 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
@@ -17,9 +19,10 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
@@ -29,14 +32,19 @@ import {
createRestoreBuildCacheFunction,
} from './functions/restoreBuildCache';
import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -48,10 +56,14 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -68,9 +80,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -81,9 +97,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,200 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { createEasBuildBuildFunctionGroup } from '../build';
function createMockBuildToolsContext(
overrides: Partial<{
platform: Platform;
simulator: boolean;
buildCredentials: Record<string, unknown>;
}> = {}
): CustomBuildContext<BuildJob> {
return {
job: {
platform: overrides.platform ?? Platform.ANDROID,
simulator: overrides.simulator ?? false,
secrets: overrides.buildCredentials
? { buildCredentials: overrides.buildCredentials }
: undefined,
},
} as unknown as CustomBuildContext<BuildJob>;
}
describe(createEasBuildBuildFunctionGroup, () => {
describe('working_directory input', () => {
it('does not set working directory on steps when not provided (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
for (const step of steps) {
// Without working_directory, only installPods would have a relative dir (iOS only).
// On Android, no step should have a relative working directory.
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
}
});
it('sets working directory on all steps except checkout (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
it('sets working directory on all steps except checkout (iOS simulator)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
const targetNamesInput = configureIosVersionStep?.inputs?.find(
input => input.id === 'target_names'
);
expect(configureIosVersionStep).toBeDefined();
expect(targetNamesInput?.rawValue).toBe(
'${{ steps.configure_ios_credentials.outputs.target_names }}'
);
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
});
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
});
it('throws for generic jobs (no platform)', () => {
const buildToolsContext = {
job: { platform: undefined },
} as unknown as CustomBuildContext<BuildJob>;
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
'Build function group is not supported in generic jobs.'
);
});
});
@@ -1,5 +1,12 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
createBuildStepsFromFunctionGroupCall: globalCtx => {
inputProviders: [
BuildStepInput.createProvider({
id: 'working_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
const workingDirectory = inputs.working_directory?.getValue({
interpolationContext: globalCtx.getInterpolationContext(),
}) as string | undefined;
if (buildToolsContext.job.platform === Platform.IOS) {
if (buildToolsContext.job.simulator) {
return createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
});
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
];
}
function createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const resolveAppleTeamIdFromCredentials =
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'resolve_apple_team_id_from_credentials',
workingDirectory,
});
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
},
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
);
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -199,11 +250,20 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
},
}),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -215,24 +275,29 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -242,6 +307,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -249,6 +315,7 @@ function createStepsForAndroidBuildWithoutCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -257,6 +324,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -264,18 +332,26 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -286,24 +362,29 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -313,6 +394,7 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -320,6 +402,7 @@ function createStepsForAndroidBuildWithCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -328,6 +411,7 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -335,21 +419,33 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runGradle,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -359,8 +455,10 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
@@ -51,7 +51,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Simulator`,
command: `
# shopt -s nullglob is necessary not to try to install
@@ -97,7 +96,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Emulator`,
command: `
# shopt -s globstar is necessary to add /**/ support
@@ -134,7 +132,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'maestro_test',
ifCondition: '${ always() }',
displayName: `maestro test ${flowPath}`,
command: `maestro test ${flowPath}`,
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { configureCredentialsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
describe(configureIosCredentialsFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
});
it('exports target_names from prepared credentials', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: createTestIosJob() },
}),
{
callInputs: {
credentials: createRawCredentials(),
},
}
);
await buildStep.executeAsync();
expect(configureCredentialsAsync).toHaveBeenCalled();
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
});
});
@@ -0,0 +1,286 @@
import { UserError } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { updateVersionsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
import { configureIosVersionFunction } from '../configureIosVersion';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
function createBuildStep({
callInputs = {},
job = createTestIosJob(),
}: {
callInputs?: Record<string, unknown>;
job?: ReturnType<typeof createTestIosJob>;
} = {}) {
return configureIosVersionFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job },
}),
{ callInputs }
);
}
describe(configureIosVersionFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
});
it('uses target_names without an explicit credentials input', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 'widget'],
build_number: '42',
},
});
await buildStep.executeAsync();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: '42',
appVersion: undefined,
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses credentials only to preserve the existing fallback path', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
callInputs: {
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses the implicit credentials default fallback from the job secrets', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
callInputs: {
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('prefers target_names when both target_names and credentials are provided', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
const buildStep = createBuildStep({
callInputs: {
target_names: ['input-target'],
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['input-target'],
buildConfiguration: 'Release',
}
);
});
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: {
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
},
});
const configureIosCredentialsStep =
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
});
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
globalCtx,
{
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
app_version: '1.2.3',
},
}
);
await new BuildWorkflow(globalCtx, {
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
buildFunctions: {},
}).executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
JSON.stringify(['app', 'widget'])
);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
configureIosVersionStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('accepts an empty target_names array without crashing', async () => {
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const buildStep = createBuildStep({
callInputs: {
target_names: [],
app_version: '1.2.3',
},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: [],
buildConfiguration: 'Release',
}
);
});
it('rejects invalid target_names', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 123],
app_version: '1.2.3',
},
});
const promise = buildStep.executeAsync();
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
});
it('uses the corrected App version error text', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app'],
app_version: 'invalid-semver',
},
});
await expect(buildStep.executeAsync()).rejects.toThrow(
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
);
});
});
@@ -0,0 +1,290 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { runEasCliCommand } from '../../../utils/easCli';
import { createEasDeployBuildFunction } from '../deploy';
jest.mock('../../../utils/easCli', () => ({
runEasCliCommand: jest.fn(),
}));
const mockDeployStdout = JSON.stringify({
url: 'https://example.dev',
production: { url: 'https://example.prod' },
aliases: [{ url: 'https://example.alias' }],
identifier: 'abc',
dashboardUrl: 'https://expo.dev/dashboard',
});
describe(createEasDeployBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
alias: 'preview-link',
prod: true,
source_maps: false,
},
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
);
expect(runEasCliCommand).toHaveBeenCalledWith({
args: [
'deploy',
'--non-interactive',
'--json',
'--export-dir',
'dist',
'--alias',
'preview-link',
'--prod',
],
options: expect.objectContaining({
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
}),
});
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
});
it('forwards workflow step env to the eas deploy process', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: true },
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--source-maps']),
})
);
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
HOME: '/tmp/home',
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
}),
}),
})
);
});
it('throws an actionable error message when deploy command fails', async () => {
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
let errorMessage = '';
try {
await buildStep.executeAsync();
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toContain('Deploy command failed.');
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
expect(errorMessage).toContain(
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
);
expect(errorMessage).toContain('Original error: deploy failed');
});
it('passes custom export_dir to eas deploy', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { export_dir: 'web-build' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--export-dir', 'web-build']),
})
);
});
it('does not pass --source-maps when source_maps is false', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: false },
});
await buildStep.executeAsync();
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
expect(args).not.toContain('--source-maps');
});
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from('not-json'),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything() }),
expect.stringContaining('Failed to parse')
);
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
});
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
const partialDeployStdout = JSON.stringify({
url: 'https://example.dev-only',
dashboardUrl: 'https://expo.dev/dashboard-only',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(partialDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
});
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
const stdout = JSON.stringify({
url: 'https://example.deployment',
aliases: [{ url: 'https://example.alias-first' }],
identifier: 'partial-alias',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(stdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
});
});
@@ -0,0 +1,122 @@
jest.mock('../../../utils/project', () => ({
runExpoCliCommand: jest.fn().mockResolvedValue({}),
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
}));
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
import { runExpoCliCommand } from '../../../utils/project';
import { createEasExportBuildFunction } from '../export';
describe(createEasExportBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
});
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running export command: expo export --output-dir dist --platform web'
);
expect(runExpoCliCommand).toHaveBeenCalledWith({
packageManager: PackageManager.NPM,
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
options: {
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
stdio: 'pipe',
},
});
expect(buildStep.outputById.export_dir.value).toBe('dist');
});
it('passes optional flags matching expo export CLI', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
output_dir: 'web-out',
dev: true,
minify: false,
dump_assetmap: true,
ssg: false,
api_only: true,
platform: 'web',
},
});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'export',
'--output-dir',
'web-out',
'--platform',
'web',
'--dev',
'--no-minify',
'--dump-assetmap',
'--no-ssg',
'--api-only',
],
})
);
expect(buildStep.outputById.export_dir.value).toBe('web-out');
});
it('uses resolved package manager (e.g. yarn)', async () => {
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({ packageManager: PackageManager.YARN })
);
});
it('throws when export command fails', async () => {
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
});
});
@@ -1,210 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -0,0 +1,302 @@
import { vol } from 'memfs';
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
import { createMockLogger } from '../../../__tests__/utils/logger';
describe(readFlowName, () => {
beforeEach(() => vol.reset());
it('returns top-level `name` when present', async () => {
vol.fromJSON({
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
});
it('falls back to basename without extension when name absent', async () => {
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is empty string', async () => {
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is non-string', async () => {
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when YAML is malformed', async () => {
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file is empty', async () => {
vol.fromJSON({ '/p/login.yml': '' });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file does not exist', async () => {
expect(await readFlowName('/p/missing.yml')).toBe('missing');
});
it('only parses the first YAML document (commands document errors are ignored)', async () => {
vol.fromJSON({
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
it('handles .yaml extension', async () => {
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
});
it('handles a leading `---` document marker', async () => {
vol.fromJSON({
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
});
describe(walkDir, () => {
beforeEach(() => vol.reset());
it('collects all .yml/.yaml files recursively, sorted', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/a.yml': '',
'/p/flows/b.yaml': '',
'/p/flows/sub/c.yml': '',
'/p/flows/sub/d.YAML': '',
'/p/flows/readme.md': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual([
'/p/flows/a.yml',
'/p/flows/b.yaml',
'/p/flows/sub/c.yml',
'/p/flows/sub/d.YAML',
]);
});
it('skips files named config.yaml (case-insensitive)', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/config.yaml': '',
'/p/flows/CONFIG.yaml': '',
'/p/flows/login.yml': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual(['/p/flows/login.yml']);
});
it('returns silently when realpath fails (e.g. missing dir)', async () => {
const logger = createMockLogger();
const out: string[] = [];
await walkDir('/p/missing', new Set(), out, logger);
expect(out).toEqual([]);
});
});
describe(buildFlowNameToPathMap, () => {
beforeEach(() => vol.reset());
it('builds a map from a single file input (basename name)', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
});
it('uses top-level name when present', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
});
it('walks a directory input and adds each flow', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/login.yml': 'name: Login\n',
'/proj/.maestro/checkout.yml': '',
'/proj/.maestro/sub/profile.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['Login', '.maestro/login.yml'],
['checkout', '.maestro/checkout.yml'],
['profile', '.maestro/sub/profile.yml'],
])
);
});
it('merges file + directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/a.yml': '',
'/proj/extra/b.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', 'extra/b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['a', '.maestro/a.yml'],
['b', 'extra/b.yml'],
])
);
});
it('skips non-existent inputs with warn', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
});
it('skips workspace config.yaml inside directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
'/proj/.maestro/login.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
});
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro/config.yaml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
});
it('returns null when two files share top-level name', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/a.yml': 'name: Login\n',
'/proj/b.yml': 'name: Login\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['a.yml', 'b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
});
it('returns null when two files share basename across directories', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login/index.yml': '',
'/proj/checkout/index.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login', 'checkout'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('returns null when name collides with another file basename', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login.yml': 'name: Other\n',
'/proj/Other.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login.yml', 'Other.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('dedupes the same physical file passed via overlapping inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
expect(logger.warn).not.toHaveBeenCalled();
});
it('uses absolute fallback when input file lives outside projectRoot', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/elsewhere/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/elsewhere/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
});
it('absolute directory input under projectRoot yields project-relative children', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/proj/.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
});
it('returns null and warns on unexpected helper failure', async () => {
const logger = createMockLogger();
const map = await buildFlowNameToPathMap({
inputFlowPaths: [Symbol('bad') as unknown as string],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
);
});
});
@@ -1,63 +1,18 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
parseFlowMetadata,
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
it('parses JUnit results and enriches with name→path map', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -69,18 +24,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
@@ -108,7 +60,7 @@ describe(parseMaestroResults, () => {
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
it('uses flow name as fallback path when nameToPath is null', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -118,49 +70,33 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', null);
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
it('uses flow name as fallback path when name not in map', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map());
expect(results[0].path).toBe('home');
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results).toEqual([]);
});
@@ -179,47 +115,152 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
// Attempt 0: login FAILED
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: login PASSED
'/junit/junit-report-flow-1-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
// Should return 2 results — one per attempt; retryCount from filename
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Timeout',
duration: 5000,
retryCount: 0,
}),
expect.objectContaining({
name: 'login',
path: '.maestro/login.yml',
status: 'passed',
errorMessage: null,
duration: 3000,
retryCount: 1,
}),
]);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
vol.fromJSON({
// Attempt 0: home FAILED, login PASSED
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: both PASSED
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -238,21 +279,14 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
@@ -262,51 +296,6 @@ describe(parseMaestroResults, () => {
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
@@ -592,3 +581,310 @@ describe(parseJUnitTestCases, () => {
expect(results[0].properties).toEqual({});
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites>
<testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Search" status="SUCCESS" time="1.0" />
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
</testsuite>
</testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([
['Login', 'flows/login.yaml'],
['Search', 'flows/search.yaml'],
['Checkout', 'flows/checkout.yaml'],
]),
});
expect(result).toEqual(['flows/checkout.yaml']);
});
it('returns null when a failing testcase has no entry in nameToPath', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['Login', 'flows/login.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when two failing testcases share the same name (collision)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Shared" status="SUCCESS" time="1.0" />
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file does not exist', async () => {
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/missing.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file is malformed', async () => {
vol.fromJSON({
'/tmp/junit-reports/bad.xml': 'this is not xml',
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/bad.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
// fast-xml-parser can produce a partial parse from truncated XML — without
// strict validation, retry-failed-only would only retry the visible failures and
// silently skip flows that were cut off.
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
<testcase name="B"`, // intentionally truncated mid-tag
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([
['A', 'flows/a.yaml'],
['B', 'flows/b.yaml'],
]),
});
expect(result).toBeNull();
});
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
</testsuite>`, // missing </testsuites>
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
});
describe('mergeJUnitReports', () => {
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
// Single-attempt runs should land in final_report_path with the same
// suite-level attributes (tests/failures/time) and non-testcase children
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
// rebuild path used for multi-attempt merges drops these, so the single
// case must short-circuit to a byte-equivalent copy.
const input = `<?xml version="1.0"?>
<testsuites>
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" status="SUCCESS" time="2.0" />
<system-out>boot complete</system-out>
</testsuite>
</testsuites>`;
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
});
await mergeJUnitReports({
sourceDir: '/tmp/junit-reports',
outputPath: '/tmp/final.xml',
});
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
expect(out).toBe(input);
});
it('keeps the latest attempt per flow name across multiple files', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="B" status="SUCCESS" time="3.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const a = testcases.find((t: any) => t['@_name'] === 'A');
const b = testcases.find((t: any) => t['@_name'] === 'B');
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
expect(b.failure).toBeUndefined();
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
// — without a throw here, mergeJUnitReports would silently write an empty
// <testsuite> and the caller would upload a misleading empty report.
vol.fromJSON({
'/tmp/r/.gitkeep': '',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no \*\.xml files/);
});
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
// Without a throw, mergeJUnitReports would silently emit an empty merged
// document and the copy-latest fallback (which only triggers on throw)
// would never run.
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/invalid XML/);
});
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite></testsuite></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no parseable testcases/);
});
it('throws when any input XML is malformed (even if others parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': 'garbage not xml',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
// fast-xml-parser is lenient and can return partial results from truncated
// XML. Without strict validation, mergeJUnitReports would emit a merged
// report missing the cut-off flows and Phase 3's copy-latest fallback
// would never run. Validation must reject the file.
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
<testcase name="B"`, // truncated mid-tag
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
</testsuite>`, // missing </testsuites>
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('preserves duplicate names within the same attempt', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Dup" status="SUCCESS" time="1.0" />
<testcase name="Dup" status="SUCCESS" time="2.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
out
);
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
});
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
});
it('throws when source has no xml files', async () => {
vol.fromJSON({ '/tmp/r/.keep': '' });
await expect(
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
).rejects.toThrow();
});
});
@@ -0,0 +1,642 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockedSpawn = jest.mocked(spawn);
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub the merge helper so tests that don't care about it don't
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries only the failed flows on the next attempt (junit mode)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Attempt 0 saw all 3
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
// Attempt 1 saw only the failed one
const a1Args = mockedSpawn.mock.calls[1][1]!;
expect(a1Args).toContain('flows/b.yaml');
expect(a1Args).not.toContain('flows/a.yaml');
expect(a1Args).not.toContain('flows/c.yaml');
});
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Both attempts saw all 3 flows
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('always retries all flows when output_format is not junit', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('runs all original flows on every retry when retry_failed_only=false', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: false,
platform: 'android',
});
await step.executeAsync();
// Both attempts must see both flows in the original input order. Tail-slice
// equality locks order, not just membership — guards against any future
// failed-first reshuffling of the dumb-retry list.
const a0 = mockedSpawn.mock.calls[0][1]!;
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(parseSpy).not.toHaveBeenCalled();
});
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('writes merged junit on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalled();
});
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
// Phase 1: final_report_path populated
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
// Retry loop: --output written into junit-reports/ (not legacy path)
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
// Phase 3: merge invoked
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
});
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
jest.mock('../../utils/ios/xcactivitylog');
describe(parseXcactivitylogFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
proxyBaseUrl: 'https://cache.example.com',
})
);
});
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
xclogparserVersion: undefined,
})
);
});
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: 'my-app/ios/Build',
workspace_path: 'my-app/ios',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
})
);
});
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: '/abs/derived',
workspace_path: '/abs/workspace',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: '/abs/derived',
workspacePath: '/abs/workspace',
})
);
});
});
@@ -0,0 +1,77 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAppConfig } from '../../../utils/appConfig';
import { createReadAppConfigBuildFunction } from '../readAppConfig';
jest.mock('../../../utils/appConfig');
describe(createReadAppConfigBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('reads and logs app configuration', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({
logger,
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
});
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
await buildStep.executeAsync();
expect(readAppConfig).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: globalContext.defaultWorkingDirectory,
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
logger: stepLogger,
sdkVersion: '54.0.0',
})
);
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
expect(stepLogger.info).toHaveBeenCalledWith(
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
);
const firstOutput = buildStep.outputById.app_config.value;
expect(firstOutput).toBeDefined();
expect(JSON.parse(firstOutput!)).toEqual({
name: 'my-app',
slug: 'my-app-slug',
version: '1.2.3',
});
});
it('does not throw if reading app config fails', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
});
it('exports app fields as returned by app config', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
await buildStep.executeAsync();
const secondOutput = buildStep.outputById.app_config.value;
expect(secondOutput).toBeDefined();
expect(JSON.parse(secondOutput!)).toEqual({
name: 'my-app',
slug: 123,
});
});
});
@@ -0,0 +1,50 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAndLogPackageJson } from '../../../utils/project';
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
jest.mock('../../../utils/project');
describe(createReadPackageJsonBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('logs package.json contents', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
await buildStep.executeAsync();
expect(readAndLogPackageJson).toHaveBeenCalledWith(
stepLogger,
globalContext.defaultWorkingDirectory
);
expect(buildStep.outputById.package_json.value).toBe(
JSON.stringify({ name: 'app', version: '1.0.0' })
);
});
it('does not throw if reading package.json fails', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
throw new Error('failed to read package.json');
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(stepLogger.error).toHaveBeenCalledWith({
err: expect.any(Error),
});
expect(buildStep.outputById.package_json.value).toBeUndefined();
});
});
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/root/project/.maestro/home.yml': '',
});
mockMutationFn.mockResolvedValue({
@@ -83,7 +83,11 @@ describe(createReportMaestroTestResultsFunction, () => {
},
});
await createStep().executeAsync();
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
@@ -238,6 +242,45 @@ describe(createReportMaestroTestResultsFunction, () => {
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('sends per-attempt results with same name but different retryCount', async () => {
vol.fromJSON({
// Two JUnit files for same flow (per-attempt)
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults).toHaveLength(2);
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
);
expect(variables.input.testCaseResults[1]).toEqual(
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
@@ -26,6 +26,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
@@ -33,15 +34,15 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
@@ -57,7 +58,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
@@ -97,6 +100,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -147,6 +156,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -174,4 +201,74 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
const step = createStep(undefined, {
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
});
await step.executeAsync();
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
it('fails early on Linux when emulator host support check fails', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
it('continues startup on Linux when emulator host support check succeeds', async () => {
await createStep().executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('skips emulator host support check when opt out env var is enabled', async () => {
const step = createStep(undefined, {
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
false
);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: undefined,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
});
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: '/android/sdk/root',
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'/android/sdk/root/emulator/emulator',
['-accel-check'],
{
env: expect.any(Object),
}
);
});
});
@@ -0,0 +1,76 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -58,6 +59,20 @@ describe(isMissingPurposeStringError, () => {
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -34,18 +34,20 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { env, inputs, outputs }) => {
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const platform =
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
@@ -42,18 +42,20 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
const channelInput = inputs.channel.value as string | undefined;
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
@@ -1,5 +1,10 @@
import { Ios } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import assert from 'assert';
import { configureCredentialsAsync } from '../utils/ios/configure';
@@ -26,9 +31,14 @@ export function configureIosCredentialsFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
outputProviders: [
BuildStepOutput.createProvider({
id: 'target_names',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
@@ -50,6 +60,7 @@ export function configureIosCredentialsFunction(): BuildFunction {
inputs.build_configuration.value as string | undefined
),
});
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
stepCtx.logger.info('Successfully configured iOS credentials');
},
@@ -1,7 +1,8 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import assert from 'assert';
import semver from 'semver';
import { z } from 'zod';
import { updateVersionsAsync } from '../utils/ios/configure';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -17,10 +18,15 @@ export function configureIosVersionFunction(): BuildFunction {
inputProviders: [
BuildStepInput.createProvider({
id: 'credentials',
required: true,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
defaultValue: '${ eas.job.secrets.buildCredentials }',
}),
BuildStepInput.createProvider({
id: 'target_names',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'build_configuration',
required: false,
@@ -38,19 +44,6 @@ export function configureIosVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
assert(stepCtx.global.staticContext.job, 'Job is not defined');
const job = stepCtx.global.staticContext.job as Ios.Job;
@@ -59,8 +52,9 @@ export function configureIosVersionFunction(): BuildFunction {
const appVersion =
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
if (appVersion && !semver.valid(appVersion)) {
throw new Error(
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
);
}
@@ -71,6 +65,33 @@ export function configureIosVersionFunction(): BuildFunction {
return;
}
const targetNamesInput = inputs.target_names.value;
let targetNames: string[];
if (targetNamesInput !== undefined) {
const parsed = z.array(z.string()).safeParse(targetNamesInput);
if (!parsed.success) {
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
'"target_names" input must be an array of strings.',
{ cause: parsed.error }
);
}
targetNames = parsed.data;
} else {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
targetNames = Object.keys(credentials.targetProvisioningProfiles);
}
stepCtx.logger.info('Setting iOS version...');
if (buildNumber) {
stepCtx.logger.info(`Build number: ${buildNumber}`);
@@ -87,7 +108,7 @@ export function configureIosVersionFunction(): BuildFunction {
appVersion,
},
{
targetNames: Object.keys(credentials.targetProvisioningProfiles),
targetNames,
buildConfiguration: resolveBuildConfiguration(
job,
inputs.build_configuration.value as string | undefined
@@ -0,0 +1,167 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { runEasCliCommand } from '../../utils/easCli';
export function createEasDeployBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'deploy',
name: 'Deploy',
__metricsId: 'eas/deploy',
inputProviders: [
BuildStepInput.createProvider({
id: 'alias',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'prod',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'source_maps',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
// Match eas/export default `output_dir`
BuildStepInput.createProvider({
id: 'export_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'deploy_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'deploy_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_deployment_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_identifier',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_dashboard_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_alias_url',
required: false,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const alias = inputs.alias.value as string | undefined;
const prod = inputs.prod.value as boolean | undefined;
const sourceMaps = inputs.source_maps.value as boolean | undefined;
const exportDir = inputs.export_dir.value as string;
const deployCommand = getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
});
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
try {
const result = await runEasCliCommand({
args: deployCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
const deployJson = result.stdout.toString();
outputs.deploy_json.set(deployJson);
const parsedDeploymentOutput = parseDeploymentOutput({
deployJson,
logger: stepsCtx.logger,
});
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'unknown error';
throw new UserError(
'EAS_DEPLOY_FAILED',
'Deploy command failed.\n\n' +
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
`Original error: ${errorMessage}`,
{ cause: error }
);
}
},
});
}
function getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
}: {
exportDir: string;
alias?: string;
prod?: boolean;
sourceMaps?: boolean;
}): string[] {
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
if (alias) {
deployCommand.push('--alias', alias);
}
if (prod) {
deployCommand.push('--prod');
}
if (sourceMaps) {
deployCommand.push('--source-maps');
}
return deployCommand;
}
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
deploy_url?: string;
deploy_deployment_url?: string;
deploy_identifier?: string;
deploy_dashboard_url?: string;
deploy_alias_url?: string;
} {
try {
const deployObject = JSON.parse(deployJson);
return {
deploy_url:
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
deploy_deployment_url: deployObject.url,
deploy_identifier: deployObject.identifier,
deploy_dashboard_url: deployObject.dashboardUrl,
deploy_alias_url: deployObject.aliases?.[0]?.url,
};
} catch (error: unknown) {
logger.warn(
{ err: error },
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
);
return {};
}
}
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
throw new Error('Eager bundle is not supported for SDK version < 52');
}
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
| string
| undefined;
@@ -0,0 +1,140 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { resolvePackageManager } from '../../utils/packageManager';
import { runExpoCliCommand } from '../../utils/project';
export function createEasExportBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'export',
name: 'Export',
__metricsId: 'eas/export',
inputProviders: [
BuildStepInput.createProvider({
id: 'output_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'dev',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'minify',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'dump_assetmap',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ssg',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'platform',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'web',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'export_dir',
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const outputDir = inputs.output_dir.value as string;
const dev = inputs.dev.value as boolean | undefined;
const minify = inputs.minify.value as boolean | undefined;
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
const ssg = inputs.ssg.value as boolean | undefined;
const apiOnly = inputs.api_only.value as boolean | undefined;
const platform = inputs.platform.value as string;
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const exportCommand = getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
});
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
try {
await runExpoCliCommand({
packageManager,
args: exportCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
stdio: 'pipe',
},
});
outputs.export_dir.set(outputDir);
} catch (error) {
throw new UserError(
'EXPO_EXPORT_FAILED',
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
},
});
}
function getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
}: {
outputDir: string;
dev?: boolean;
minify?: boolean;
dumpAssetmap?: boolean;
ssg?: boolean;
apiOnly?: boolean;
platform: string;
}): string[] {
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
if (dev) {
exportCommand.push('--dev');
}
if (minify === false) {
exportCommand.push('--no-minify');
}
if (dumpAssetmap) {
exportCommand.push('--dump-assetmap');
}
if (ssg === false) {
exportCommand.push('--no-ssg');
}
if (apiOnly) {
exportCommand.push('--api-only');
}
return exportCommand;
}
@@ -6,6 +6,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { templateString } from '@expo/template-file';
import assert from 'assert';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -36,6 +37,10 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -55,6 +60,9 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
export function generateGymfileFromTemplateFunction(): BuildFunction {
@@ -181,6 +189,7 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
ICLOUD_CONTAINER_ENVIRONMENT,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: './build',
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
...(PROFILES ? { PROFILES } : {}),
...(credentials
? {
@@ -27,7 +27,7 @@ export async function installNodeModules(
env: BuildStepEnv
): Promise<void> {
const { logger } = stepCtx;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
if (packagerRunDir !== stepCtx.workingDirectory) {
@@ -1,686 +0,0 @@
import { GenericArtifactType } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { Result, asyncResult, result } from '@expo/results';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
spawnAsync,
} from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: '__maestro_test',
__metricsId: 'eas/__maestro_test',
inputProviders: [
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'platform',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
id: 'flow_paths',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'retries',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'include_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'exclude_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'shards',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'output_format',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
id: 'record_screen',
defaultValue: false,
required: false,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
const {
platform,
flow_paths,
retries,
include_tags,
exclude_tags,
shards,
output_format,
record_screen,
} = z
.object({
platform: z.enum(['ios', 'android']),
flow_paths: z.array(z.string()),
retries: z.number().default(1),
include_tags: z.string().optional(),
exclude_tags: z.string().optional(),
shards: z.number().default(1),
output_format: z.string().optional(),
record_screen: z.boolean().default(false),
})
.parse(
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
);
const flowPathsToExecute: string[] = [];
for (const flowPath of flow_paths) {
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: stepCtx.workingDirectory,
flowPath,
logger: stepCtx.logger,
includeTags: include_tags ? include_tags.split(',') : undefined,
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
});
if (flowPaths.length === 0) {
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
continue;
}
stepCtx.logger.info(
`Marking for execution:\n- ${flowPaths
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
stepCtx.logger.info('');
flowPathsToExecute.push(...flowPaths);
}
// TODO: Add support for shards. (Shouldn't be too difficult.)
if (shards > 1) {
stepCtx.logger.warn(
'Sharding support has been temporarily disabled. Running tests on a single shard.'
);
}
// eas/__maestro_test does not start devices, it expects a single device to be already running
// and configured with the app. Here we find the booted device and stop it.
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
switch (platform) {
case 'ios': {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (bootedDevices.length === 0) {
throw new Error('No booted iOS Simulator found.');
} else if (bootedDevices.length > 1) {
throw new Error('Multiple booted iOS Simulators found.');
}
const device = bootedDevices[0];
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
stepCtx.logger.info(`Preparing Simulator for tests...`);
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
logger: stepCtx.logger,
stdio: 'pipe',
});
sourceDeviceIdentifier = device.name;
break;
}
case 'android': {
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
if (connectedDevices.length === 0) {
throw new Error('No booted Android Emulator found.');
} else if (connectedDevices.length > 1) {
throw new Error('Multiple booted Android Emulators found.');
}
const { serialId } = connectedDevices[0];
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
mode: PipeMode.COMBINED,
env,
});
const avdName = adbEmuAvdNameResult.stdout
.replace(/\r\n/g, '\n')
.split('\n')[0] as AndroidVirtualDeviceName;
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
stepCtx.logger.info(`Preparing Emulator for tests...`);
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
stdio: 'pipe',
});
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
await setTimeout(1000);
sourceDeviceIdentifier = avdName;
break;
}
}
// During tests we generate reports and device logs. We store them in temporary directories
// and upload them once all tests are done. When a test is retried, new reports overwrite
// the old ones. The files are named "flow-${index}" for easier identification.
const maestroReportsDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'maestro-reports-')
);
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
const failedFlows: string[] = [];
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
// If output_format is empty or noop, we won't use this.
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
// If the test passes, but the recording fails, we don't want to make the test fail,
// so we return two separate results.
const {
fnResult: { fnResult, recordingResult },
logsResult,
} = await withCleanDeviceAsync({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger: stepCtx.logger,
fn: async ({ deviceIdentifier }) => {
return await maybeWithScreenRecordingAsync({
shouldRecord: record_screen,
platform,
deviceIdentifier,
env,
logger: stepCtx.logger,
fn: async () => {
stepCtx.logger.info('');
const [command, ...args] = getMaestroTestCommand({
flow_path: flowPath,
output_format,
output_path: outputPath,
});
try {
await spawnAsync(command, args, {
logger: stepCtx.logger,
cwd: stepCtx.workingDirectory,
env,
stdio: 'pipe',
});
} finally {
stepCtx.logger.info('');
}
},
});
},
});
// Move device logs to the device logs directory.
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
await fs.promises.rm(destinationPath, {
force: true,
recursive: true,
});
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
}
} else if (logsResult?.reason) {
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
}
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
if (isLastAttempt && recordingResult.value) {
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
name: `Screen Recording (${flowIndex}-${path.basename(
flowPath,
path.extname(flowPath)
)})`,
paths: [recordingResult.value],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
}
}
if (fnResult.ok) {
stepCtx.logger.info(`Test passed.`);
// Break out of the retry loop.
break;
}
if (attemptCount < retries - 1) {
stepCtx.logger.info(`Retrying test...`);
stepCtx.logger.info('');
continue;
}
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
failedFlows.push(flowPath);
}
}
stepCtx.logger.info('');
// When all tests are done, we upload the reports and device logs.
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
if (generatedMaestroReports.length === 0) {
stepCtx.logger.warn('No reports were generated.');
} else {
stepCtx.logger.info(`Uploading reports...`);
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
paths: [maestroReportsDir],
type: GenericArtifactType.OTHER,
},
});
if (artifactId) {
outputs.test_reports_artifact_id.set(artifactId);
}
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload reports.');
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
} else {
stepCtx.logger.info(`Uploading device logs...`);
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `Maestro Test Device Logs`,
paths: [deviceLogsDir],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
}
}
stepCtx.logger.info('');
// If any tests failed, we throw an error to mark the step as failed.
if (failedFlows.length > 0) {
throw new Error(
`Some Maestro tests failed:\n- ${failedFlows
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
} else {
stepCtx.logger.info('All Maestro tests passed.');
}
},
});
}
export function getMaestroTestCommand(params: {
flow_path: string;
output_format: string | undefined;
/** Unused if `output_format` is undefined */
output_path: string;
}): [command: string, ...args: string[]] {
let outputFormatFlags: string[] = [];
if (params.output_format) {
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
}
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
command: string,
...args: string[],
];
}
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
junit: 'xml',
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger,
fn,
}: {
env: BuildStepEnv;
logger: bunyan;
platform: 'ios' | 'android';
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
fn: ({
deviceIdentifier,
}: {
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
}) => Promise<TResult>;
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await IosSimulatorUtils.cloneAsync({
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
destinationDeviceName: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
await IosSimulatorUtils.waitForReadyAsync({
udid,
env,
});
localDeviceIdentifier = localDeviceName as IosSimulatorName;
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
// Stop the device
let logsResult: Result<{ outputPath: string }> | null = null;
try {
switch (platform) {
case 'ios': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
IosSimulatorUtils.collectLogsAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await IosSimulatorUtils.deleteAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
});
break;
}
case 'android': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
}
}
} catch (err) {
logger.error(`Error cleaning up device: ${err}`);
}
return { fnResult: fnResult.enforceValue(), logsResult };
}
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
async function maybeWithScreenRecordingAsync<TResult>({
shouldRecord,
platform,
deviceIdentifier,
env,
logger,
fn,
}: {
// As weird as it is, it's more convenient to have this function like `maybeWith...`
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
shouldRecord: boolean;
platform: 'ios' | 'android';
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
env: BuildStepEnv;
logger: bunyan;
fn: () => Promise<TResult>;
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
if (!shouldRecord) {
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
}
let recordingResult: Result<{
recordingSpawn: SpawnPromise<SpawnResult>;
outputPath?: string;
}>;
// Start screen recording
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
recordingResult = await asyncResult(
IosSimulatorUtils.startScreenRecordingAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorName,
env,
})
);
break;
}
case 'android': {
recordingResult = await asyncResult(
AndroidEmulatorUtils.startScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
env,
})
);
break;
}
}
if (!recordingResult.ok) {
logger.warn('Failed to start screen recording.', recordingResult.reason);
}
// Run the function
const fnResult = await asyncResult(fn());
// If recording failed there's nothing to stop, so we return the results
if (!recordingResult.ok) {
return { fnResult, recordingResult: result(recordingResult.reason) };
}
// If recording started, finish it
try {
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
await IosSimulatorUtils.stopScreenRecordingAsync({
recordingSpawn: recordingResult.value.recordingSpawn,
});
return {
fnResult,
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
recordingResult: result(recordingResult.value.outputPath!),
};
}
case 'android': {
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
recordingSpawn: recordingResult.value.recordingSpawn,
env,
});
return { fnResult, recordingResult: result(outputPath) };
}
}
} catch (err) {
logger.warn('Failed to stop screen recording.', err);
return { fnResult, recordingResult: result(err as Error) };
}
}
@@ -0,0 +1,190 @@
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import path from 'path';
import yaml from 'yaml';
const YAML_EXT = /\.ya?ml$/i;
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
export interface BuildFlowNameToPathMapArgs {
inputFlowPaths: string[];
projectRoot: string;
logger: bunyan;
}
interface FlowEntry {
name: string;
path: string;
realpath: string;
}
export async function buildFlowNameToPathMap(
args: BuildFlowNameToPathMapArgs
): Promise<Map<string, string> | null> {
try {
const flows = await discoverFlows(args);
return dedupAndDetectDuplicates(flows, args.logger);
} catch (err: any) {
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
return null;
}
}
async function discoverFlows({
inputFlowPaths,
projectRoot,
logger,
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
const realRootResult = await asyncResult(fs.realpath(projectRoot));
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
const fileLists = await Promise.all(
inputFlowPaths.map(async input => {
const abs = path.resolve(projectRoot, input);
const statResult = await asyncResult(fs.stat(abs));
if (!statResult.ok) {
logger.warn(`flow_path entry "${input}" not found, skipping`);
return [];
}
const stat = statResult.value;
if (stat.isFile() && YAML_EXT.test(abs)) {
return [abs];
}
if (stat.isDirectory()) {
const out: string[] = [];
await walkDir(abs, new Set(), out, logger);
return out;
}
return [];
})
);
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
}
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
const [name, realFileResult] = await Promise.all([
readFlowName(absFile),
asyncResult(fs.realpath(absFile)),
]);
const realFile = realFileResult.ok ? realFileResult.value : absFile;
const rel = path.relative(realRoot, realFile);
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
return { name, path: value, realpath: realFile };
}
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
const byKey = new Map<string, FlowEntry>();
for (const f of flows) {
if (!byKey.has(f.realpath)) {
byKey.set(f.realpath, f);
}
}
const unique = [...byKey.values()];
const nameToPath = new Map<string, string>();
const duplicates = new Map<string, string[]>();
for (const f of unique) {
if (nameToPath.has(f.name)) {
const existing = nameToPath.get(f.name)!;
const list = duplicates.get(f.name) ?? [existing];
list.push(f.path);
duplicates.set(f.name, list);
}
nameToPath.set(f.name, f.path);
}
if (duplicates.size > 0) {
for (const [name, paths] of duplicates) {
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
}
logger.warn(
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
);
return null;
}
return nameToPath;
}
export async function walkDir(
dir: string,
visited: Set<string>,
out: string[],
logger: bunyan
): Promise<void> {
// Cycle protection: dedup on realpath (fall back to the original path when
// realpath fails so we still make progress on missing or restricted dirs).
const realResult = await asyncResult(fs.realpath(dir));
const real = realResult.ok ? realResult.value : dir;
if (visited.has(real)) {
return;
}
visited.add(real);
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: any) {
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
const tgtResult = await asyncResult(fs.stat(full));
if (!tgtResult.ok) {
continue;
}
const tgt = tgtResult.value;
if (tgt.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
} else if (entry.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (entry.isFile()) {
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
}
}
}
export async function readFlowName(absFile: string): Promise<string> {
const ext = path.extname(absFile);
const fallback = path.basename(absFile, ext);
let content: string;
try {
content = await fs.readFile(absFile, 'utf-8');
} catch {
return fallback;
}
let firstDoc;
try {
const docs = yaml.parseAllDocuments(content);
firstDoc = docs[0];
} catch {
return fallback;
}
if (!firstDoc || firstDoc.errors.length > 0) {
return fallback;
}
const parsed = firstDoc.toJS();
const name = parsed?.name;
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallback;
}
function isYamlFile(name: string): boolean {
return YAML_EXT.test(name);
}
function isWorkspaceConfig(name: string): boolean {
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
}
@@ -1,7 +1,6 @@
import { XMLParser } from 'fast-xml-parser';
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;
@@ -14,13 +13,8 @@ export interface MaestroFlowResult {
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export interface JUnitTestCaseResult {
name: string;
@@ -38,6 +32,86 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
return results;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
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';
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Malformed XML — return whatever we collected before the parser bailed.
}
return results;
}
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return parseJUnitContent(content);
} catch {
return [];
}
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -51,214 +125,307 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
const perFile = await Promise.all(
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
);
return perFile.flat();
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
nameToPath: Map<string, string> | null
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
interface JUnitResultWithSource {
result: JUnitTestCaseResult;
sourceFile: string;
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
const junitResultsWithSource: JUnitResultWithSource[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
junitResultsWithSource.push({ result, sourceFile: xmlFile });
}
}
// 3. Merge: JUnit results + ai-*.json metadata
if (junitResultsWithSource.length === 0) {
return [];
}
const results: MaestroFlowResult[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
group.push(entry);
resultsByName.set(entry.result.name, group);
}
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
for (const [flowName, flowEntries] of resultsByName) {
const flowPath = nameToPath?.get(flowName) ?? flowName;
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
return results;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
/**
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
* then falls back to dumb retry — re-run everything).
*
* Caller passes a precomputed `nameToPath` map built from the user's
* `inputFlowPaths`. We translate failed testcase names to flow paths via
* that map; unknown names → null.
*/
export async function parseFailedFlowsFromJUnit(args: {
junitFile: string;
nameToPath: Map<string, string>;
}): Promise<string[] | null> {
// fast-xml-parser is lenient — truncated XML can produce a partial parse
// with some testcases dropped. Trusting that subset for retry-failed-only would
// skip retries for cut-off flows; reject any malformed XML and fall back
// to dumb retry.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
const testcases = parseJUnitContent(content);
if (testcases.length === 0) {
return null;
}
return relative;
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Two testcases with the same name (pass+fail or fail+fail) make it
// impossible to map back to a single input flow_path. Signal "unknown".
// Complementary to the map-build duplicate check (which catches the case
// where tag filter hides duplication from JUnit).
const allNameCounts = new Map<string, number>();
for (const tc of testcases) {
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
}
for (const count of allNameCounts.values()) {
if (count > 1) {
return null;
}
}
const matched: string[] = [];
for (const tc of failing) {
const p = args.nameToPath.get(tc.name);
if (p === undefined) {
return null;
}
matched.push(p);
}
return matched;
}
/**
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
* attempt's <testcase> per unique flow name (latest determined from the
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
* and writes a merged document to outputPath.
*
* Throws on empty/malformed/no-testcase input so the caller can fall back to
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
* failure rows around and produce a misleading merged report.
*/
export async function mergeJUnitReports(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
}
interface FileGroup {
attemptIndex: number;
filename: string;
content: string;
testcasesByName: Map<string, unknown[]>;
}
const contents = await Promise.all(
xmlFiles.map(async f => ({
filename: f,
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
}))
);
const fileGroups: FileGroup[] = [];
for (const { filename, content } of contents) {
if (XMLValidator.validate(content) !== true) {
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
}
let parsed: any;
try {
parsed = xmlParser.parse(content);
} catch (err) {
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
}
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
if (!Array.isArray(cases)) {
continue;
}
for (const tc of cases) {
const name = tc?.['@_name'];
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
group.push(tc);
testcasesByName.set(name, group);
}
}
if (testcasesByName.size === 0) {
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
}
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
}
// Single attempt: copy the original XML so suite-level metadata (testsuite
// attributes, <system-out>, etc.) survives. The rebuild path below would
// collapse those to a single attribute-less <testsuite>.
if (fileGroups.length === 1) {
await fs.writeFile(args.outputPath, fileGroups[0].content);
return;
}
// For each unique name, pick the file with the highest attempt index that
// contains it (ties broken by sorted filename — later wins). Preserve every
// <testcase> element from the winning file for that name, so same-attempt
// duplicates survive.
const nameToWinningFile = new Map<string, FileGroup>();
for (const group of fileGroups) {
for (const name of group.testcasesByName.keys()) {
const current = nameToWinningFile.get(name);
if (!current || group.attemptIndex >= current.attemptIndex) {
nameToWinningFile.set(name, group);
}
}
}
// Emit in first-seen order (iteration over `fileGroups` yields stable order
// matching the sorted filename list).
const testcases: unknown[] = [];
const emitted = new Set<string>();
for (const group of fileGroups) {
for (const [name, cases] of group.testcasesByName) {
if (emitted.has(name)) {
continue;
}
const winner = nameToWinningFile.get(name);
if (winner === group) {
for (const tc of cases) {
testcases.push(tc);
}
emitted.add(name);
}
}
}
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
suppressEmptyNode: true,
});
const xml = builder.build({
testsuites: {
testsuite: {
testcase: testcases,
},
},
});
await fs.writeFile(args.outputPath, xml);
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* Used as a fallback when mergeJUnitReports fails due to data issues but the
* step still needs to produce final_report_path.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
export async function copyLatestAttemptXml(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`No *.xml files found in ${args.sourceDir}`);
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins
// (same rule as mergeJUnitReports).
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
return m ? parseInt(m[1], 10) : 0;
})();
for (let i = 1; i < xmlFiles.length; i++) {
const candidate = xmlFiles[i];
const match = candidate.match(ATTEMPT_PATTERN);
const attempt = match ? parseInt(match[1], 10) : 0;
if (attempt >= winnerAttempt) {
winner = candidate;
winnerAttempt = attempt;
}
}
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
}
@@ -0,0 +1,330 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
function parseInput<S extends z.ZodTypeAny>(
schema: S,
value: unknown,
message: string
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
}
return result.data;
}
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
// fault, so the post-loop merge should fall through to copy-latest instead
// of throwing.
function isFilesystemError(err: any): boolean {
if (!err || typeof err !== 'object') {
return false;
}
const code = err.code;
return (
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
);
}
// `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;
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()}`);
}
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(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'retries',
required: false,
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'retry_failed_only',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'include_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'exclude_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_format',
required: false,
defaultValue: 'junit',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'platform',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger, global } = stepCtx;
const platformInput = inputs.platform.value as string | undefined;
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
const includeTags = inputs.include_tags.value as string | undefined;
const excludeTags = inputs.exclude_tags.value as string | undefined;
const platform: 'ios' | 'android' =
platformInput === 'ios' || platformInput === 'android'
? platformInput
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
? 'ios'
: 'android';
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
// this env and writes debug output under $HOME/.maestro/tests; the step
// must read from the same place or stale files leak across runs.
const home = env.HOME;
if (!home) {
throw new SystemError('HOME env var is not set');
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
'flow_path must be a non-empty array of non-empty strings.'
);
const retries = parseInput(
RetriesSchema,
inputs.retries.value,
'retries must be a non-negative integer.'
);
const shards = parseInput(
ShardsSchema,
inputs.shards.value,
'shards must be a positive integer.'
);
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
} catch (err) {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; retry-failed-only disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// 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. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
});
logger.info(
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
try {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
signal,
});
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
}
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
);
} else {
flowsToRun = flowPaths;
logger.info('Test failed; could not determine failed subset, retrying all flows');
}
} else {
flowsToRun = flowPaths;
logger.info('Test failed, retrying all flows');
}
await sleepAsync(2000);
}
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Filesystem errors short-circuit straight to SystemError.
if (finalReportPath !== undefined) {
try {
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
}
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
);
}
},
});
}
@@ -0,0 +1,52 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
export function parseXcactivitylogFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'parse_xcactivitylog',
name: 'Analyze build performance',
__metricsId: 'eas/parse_xcactivitylog',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'derived_data_path',
required: false,
defaultValue: 'ios/build',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'workspace_path',
required: false,
defaultValue: 'ios',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'xclogparser_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, env }) => {
const derivedDataPath = inputs.derived_data_path.value as string;
const workspacePath = inputs.workspace_path.value as string;
const version = inputs.xclogparser_version.value as string | undefined;
await parseAndReportXcactivitylog({
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
}
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
fn: async (stepCtx, { inputs, env }) => {
const { logger } = stepCtx;
const appleTeamId = inputs.apple_team_id.value as string | undefined;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
const job = stepCtx.global.staticContext.job;
@@ -0,0 +1,43 @@
import { Metadata } from '@expo/eas-build-job';
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAppConfig } from '../../utils/appConfig';
export function createReadAppConfigBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_app_config',
name: 'Read app config',
__metricsId: 'eas/read_app_config',
outputProviders: [
BuildStepOutput.createProvider({
id: 'app_config',
required: false,
}),
],
fn: async (stepCtx, { env, outputs }) => {
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
stepCtx.logger.info('Using app configuration:');
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
outputs.app_config.set(JSON.stringify(appConfig));
} catch {
// readAppConfig logs underlying failures.
}
},
});
}
@@ -0,0 +1,26 @@
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAndLogPackageJson } from '../../utils/project';
export function createReadPackageJsonBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_package_json',
name: 'Read package.json',
__metricsId: 'eas/read_package_json',
outputProviders: [
BuildStepOutput.createProvider({
id: 'package_json',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
try {
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
outputs.package_json.set(JSON.stringify(packageJson));
} catch (err: unknown) {
stepCtx.logger.error({ err });
}
},
});
}
@@ -23,6 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -51,6 +52,11 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'js_bundle_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -82,7 +88,10 @@ export function createRepackBuildFunction(): BuildFunction {
);
}
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
const repackSpawnAsync = createSpawnAsyncStepAdapter({
verbose,
logger: stepsCtx.logger,
});
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
const workingDirectory = path.join(tmpDir, 'working-directory');
@@ -98,13 +107,20 @@ export function createRepackBuildFunction(): BuildFunction {
sourcemapOutput: undefined,
}
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: inputs.repack_version.value as string,
version: resolvedRepackVersion,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -118,6 +134,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
@@ -146,6 +163,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
androidSigningOptions,
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -1,9 +1,13 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
@@ -38,6 +42,11 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'flow_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
@@ -51,27 +60,46 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
// share the same name (Maestro config override), which we can't disambiguate.
// Same name with different retryCount is expected (per-attempt results from retries).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
}
seen.add(key);
}
if (conflicting.size > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
@@ -7,7 +7,10 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -16,7 +19,9 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -50,6 +55,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -176,6 +190,112 @@ export async function restoreCcacheAsync({
}
}
export async function restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
[
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
'',
'beforeSettings { settings ->',
' try {',
' settings.caches {',
' cleanup = Cleanup.ALWAYS',
' buildCache {',
' setRemoveUnusedEntriesAfterDays(3)',
' }',
' }',
' println "[EAS] Configured Gradle cache cleanup via init script"',
' } catch (Exception e) {',
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
' }',
'}',
'',
'gradle.buildFinished {',
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
'}',
'',
].join('\n')
);
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
});
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
logger.info(
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
Datadog.log(`Gradle cache restored (${hitType})`, {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
});
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
} else {
logger.warn('Failed to restore Gradle cache: ', err);
}
}
}
export async function cacheStatsAsync({
logger,
env,
@@ -10,8 +10,13 @@ import {
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
try {
await fs.promises.access(buildCachePath);
} catch {
logger.warn('No Gradle build cache found, skipping save');
return;
}
try {
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Saving Gradle cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
size,
platform: Platform.ANDROID,
});
} catch (err) {
logger.error({ err }, 'Failed to save Gradle cache');
}
}
@@ -0,0 +1,170 @@
import { SystemError } 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';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
});
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}
@@ -1,5 +1,11 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
@@ -57,6 +71,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
@@ -107,6 +130,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
@@ -191,6 +221,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: cloneSerialId,
});
}
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
@@ -230,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
},
});
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<void> {
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
if (!isAndroidEmulatorHostSupported) {
throw new UserError(
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
'Could not start the Android emulator on this runner.\n\n' +
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
' runs_on: linux-medium-nested-virtualization\n' +
' runs_on: linux-large-nested-virtualization\n\n' +
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
);
}
}
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
try {
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
return true;
} catch {
return false;
}
}
@@ -0,0 +1,140 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
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 { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({ port: z.number() });
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
// `argent mcp` is the public entry that triggers @argent/tools-client
// to spawn the tool-server detached + unref'd, so the tool-server
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
// 30-min idle shutdown that would otherwise tear the tunnel down.
spawnDetached({
command: 'bunx',
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
const { port: toolServerPort } = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const toolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the tool-server and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
function parseArgentToolServerState(raw: string): { port: number } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
@@ -65,6 +65,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
@@ -96,6 +102,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({
udid: cloneUdid,
env,
@@ -0,0 +1,55 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Preview URL: ${previewUrl}`);
logger.info(`Stream URL: ${streamUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl, streamUrl },
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
@@ -266,12 +266,31 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
@@ -279,7 +298,12 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
@@ -331,6 +355,10 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -23,18 +23,27 @@ export async function runGradleCommand({
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
});
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...env,
...extraEnv,
LC_ALL: 'C.UTF-8',
},
}
);
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -34,11 +34,13 @@ export namespace AscApiUtils {
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
@@ -108,19 +110,26 @@ export namespace AscApiUtils {
throw error;
}
}
}
async function getVisibleAppsSummaryAsync(
client: Pick<AscApiClient, 'getAsync'>
): Promise<string | null> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: 10,
});
if (appsResponse.data.length === 0) {
return ' (none)';
export async function getAppsAsync({
client,
limit,
}: {
client: Pick<AscApiClient, 'getAsync'>;
limit?: number;
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: limit ?? 10,
});
return appsResponse.data;
}
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
return (
apps
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n') || ' (none)'
);
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
message: expect.stringMatching(
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
),
})
);
@@ -96,6 +96,31 @@ describe('AscApiUtils', () => {
});
});
describe('formatAppsList', () => {
it('formats visible apps', () => {
expect(
AscApiUtils.formatAppsList([
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
{
type: 'apps',
id: '2222222222',
attributes: { name: 'Second App', bundleId: 'com.second.app' },
},
])
).toBe(
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
);
});
it('returns a placeholder when no visible apps are found', () => {
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
});
});
describe('createBuildUploadAsync', () => {
it('throws UserError when ASC duplicate version error is returned', async () => {
const payload = {
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
},
buildConfiguration: 'Release',
};
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials for multi target project', async () => {
vol.fromJSON(
{
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff. Show more