Compare commits

..
Author SHA1 Message Date
AbbanMustafa 0f2823fa18 [build-tools] Add npm cache fallback to install_node_modules step 2026-07-01 15:50:05 -04:00
AbbanMustafa 52e6fd0e81 use cache url as signal 2026-07-01 15:50:04 -04:00
AbbanMustafa 3957f058e9 inline env, remove deprecated env 2026-07-01 13:35:27 -04:00
AbbanMustafa 8597524f32 cache install test 2026-07-01 01:14:01 -04:00
AbbanMustafa 7c5190a4ab [build-tools] Apply npm cache registry only to the dependency install 2026-06-30 23:39:51 -04:00
Stanisław Chmiela 9247f3cf78 [eas-cli] Upgrade apple-utils to 2.1.22 (#3913)
## Summary

- [eas-cli] Upgrade `@expo/apple-utils` from `2.1.19` to `2.1.22`.
- Refresh the Yarn lockfile entry for the new package tarball.
- Add a changelog entry under the unreleased `main` section.

## Why

This picks up the latest published Apple API helpers, including recent App Store Connect pagination, SIWA, and review-related fixes from `@expo/apple-utils`.

## Validation

CI should pass.
2026-06-30 18:33:59 +02:00
Stanisław Chmiela bde331da63 [eas-cli] Fix managed tvOS env typecheck (#3920)
## Summary
- fix main typecheck after `Env` was widened to allow undefined values
- align `getManagedTvBuildSettings` with the shared `Env` type instead of requiring `Record<string, string>`

## Test plan

CI should pass.
2026-06-30 12:13:25 +00:00
Stanisław Chmiela fd055f08b3 [eas-build-job] Allow undefined env values (#3900)
## Summary

Widens the shared build `Env` type to allow `undefined` values, matching Node/process env semantics where undefined entries can represent absent values.

## Details

- Changes `Env` to `Record<string, string | undefined>` in `@expo/eas-build-job`.
- Allows `ctx.reportError` extras to contain undefined values for diagnostic metadata.
- Updates strict string boundaries to explicitly require values where needed, such as GraphQL IDs and static context API URLs.
- Filters undefined values out of `displayRuntimeInfo` string-only display maps.

## Validation

CI should pass.
2026-06-30 14:03:53 +02:00
Stanisław Chmiela 785d690e65 [build-tools] Enable simulator accessibility prefs before boot (#3914)
## Summary

- Add an opt-in `enable_accessibility_settings` boolean input to `eas/start_ios_simulator`.
- When enabled, call `IosSimulatorUtils.enableAccessibilitySettingsAsync` inline immediately before each `IosSimulatorUtils.startAsync` call, including cloned simulators.
- Reuse the existing `xcrun simctl list devices --json --no-escape-slashes available` wrapper so the `dataPath` from simctl JSON is used directly, then do an exact UDID/name equality check in JS. This avoids `simctl` search-term substring matches like `iPhone 17` matching `iPhone 17 Pro`.
- Treat invalid requested simulator state as `UserError`; wrap command/filesystem setup failures as `SystemError`.

## Why

These accessibility preferences help Argent and other AX-tree based tools inspect Simulator state more reliably. They can influence app/runtime accessibility behavior, so the setting is explicit and default-off rather than applied to every simulator startup.

## Maestro compatibility

`enable_accessibility_settings` defaults to `false`, so existing Maestro test runs and other `eas/start_ios_simulator` users keep the previous startup behavior unless they opt in. When enabled, the setup only writes preferences for an exact, available, shutdown simulator before `simctl bootstatus -b`. If the requested simulator is not available or not shutdown, the step fails with `UserError`; if the setup command/filesystem work fails, it fails with `SystemError`.

## Notes

- `plutil` is a standard macOS utility (`/usr/bin/plutil` on the local validation machine), so this does not add an external tool installation requirement.

## Validation

CI should pass.
2026-06-30 11:02:21 +02:00
Kudo Chien 47335f02f0 [build-tools] Fix repack AAB output path (#3892) 2026-06-30 14:38:06 +08:00
Douglas Lowder 75d9be813f [eas-cli] Resolve ApplePlatform.TV_OS for managed tvOS targets via EXPO_TV (#3907)
# Why

Non interactive builds were attempting to incorrectly verify tvOS provisioning profiles with settings for the iOS platform.

# How

Recognize when EXPO_TV env variable is set, and in that case, set the target platform to tvOS.

Fixes #3905

# Test Plan

- Tested with a real SDK 56 TV project
- New unit tests added
2026-06-29 18:32:47 -07:00
Expo CI cdaa011ce3 update CHANGELOG.md 2026-06-29 21:45:52 +00:00
Expo CI ee6e197587 v20.5.0 2026-06-29 21:43:22 +00:00
Gabe Debes 63baff4daf [eas-cli] Connect existing PostHog accounts via browser handoff (#3895) 2026-06-29 14:33:53 -07:00
Stanisław Chmiela 85a4054b17 [eas-cli] Use simulator session dashboard URLs (#3904)
## Why

The Expo dashboard route for device run sessions moved to the simulator sessions surface, so EAS CLI should send users to that page and use the simulator-session terminology in user-facing output.

## How

- Add a dashboard URL helper that points device run sessions at `/simulator-sessions/{id}`.
- Update `eas simulator:{start,get,list}` output and JSON to expose `deviceRunSessionUrl` using the simulator sessions route.
- Update user-facing simulator command copy from "device run session" to "simulator session".

## Test Plan

CI should pass.
2026-06-29 16:03:04 +02:00
Stanisław Chmiela 15cd0c5ae2 [worker] Disable Homebrew auto-update in EAS (#3912)
# Why

I don't think auto-updating Homebrew in CI makes sense. It creates a harder-to-reproduce / inconsistent environment.

# How

Added `HOMEBREW_NO_AUTO_UPDATE=1` to worker env.

# Test Plan

Next time https://expo.dev/accounts/gwdp/projects/expo-go-maestro/workflows/019f12db-23cd-78af-866b-3af410ccc10f runs I'll confirm `xcodes` does not update other formulas.
2026-06-29 13:39:00 +02:00
Stanisław Chmiela efc78f43d0 [ci] Update codemention action pin (#3903)
## Summary

Updates the codemention GitHub Action pin from `v1.4.0` to a reviewed post-`v1.5.2` upstream commit:

- `tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b`
- keeps the action pinned to an immutable commit SHA because this workflow runs on `pull_request_target` with `pull-requests: write`
- avoids pinning to moving `main` or a mutable tag

## Security review

I reviewed upstream `tobyhs/codemention` changes from `v1.4.0` through `v1.5.2` and found that `v1.5.2` introduced `[email protected]`, which currently has critical/high production dependency advisories. I therefore did not pin to `v1.5.2`.

The selected post-release commit includes follow-up dependency fixes on upstream `main`, including `[email protected]`, `[email protected]`, and `[email protected].0`. `npm audit --omit=dev` on that upstream commit reported 0 vulnerabilities at review time.

## Validation

- `yarn fmt:check .github/workflows/codemention.yml`
- YAML parse check
- `git diff --check`
2026-06-26 14:21:30 +02:00
a905a50f65 [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) (#3882)
* [eas-cli] Add tiered build-credit usage warnings (approaching/at/over)

Lowers the warning threshold from 85% to 80% to match email
notifications. Adds two new tiers beyond the existing "approaching":
one when the user has reached their included credits, and one when
they've exceeded them on a paid plan. Free plans collapse the "over"
case into the "at" message since Free is blocked at the limit.

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

* [eas-cli] Add CHANGELOG entry for tiered usage warnings (#3882)

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

---------

Co-authored-by: Sarah Lane <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-25 15:29:20 -06:00
Expo CI b36b5b36d7 update CHANGELOG.md 2026-06-25 21:14:47 +00:00
Expo CI f867b7b9f2 v20.4.0 2026-06-25 21:12:10 +00:00
David Mokos 99fba8c814 [eas-cli] share agent templates from llm-configs (#3865)
Syncs the generated app `AGENTS.md` and `CLAUDE.md` templates from `expo/llm-configs` during EAS CLI `prepack`, before the package is rebuilt for publishing. Keeps the synced templates bundled in EAS CLI so `eas project:new` still works offline and does not add a runtime `@expo/llm-configs` dependency.

Verified with `yarn workspace eas-cli sync-agent-templates`, byte-for-byte comparison against the upstream raw files, `yarn workspace eas-cli test src/commandUtils/new/__tests__/projectFiles.test.ts --runInBand`, `yarn workspace eas-cli typecheck-for-build`, and `yarn workspace eas-cli build`. Previously also ran a local `eas project:new` smoke test and confirmed generated agent files matched the shared templates.
2026-06-25 13:34:53 +02:00
Gabe Debes 49101c06c1 [eas-cli] Auto-create branch when channel has no branches mapped (#3891)
`eas update --channel <name>` was failing with `Channel has no branches associated with it` when the channel existed but had no branch mapped.

This trips up fresh projects: `eas build` auto-creates the channel without a branch, so by the time you run `eas update` the channel already exists. The CLI only ever auto-created a branch when the channel was *missing* (`ChannelNotFoundError`), so the existing-but-empty channel fell through to the error instead.

Now the empty-channel case does the same thing as the missing-channel case: create a branch with the channel's name and map it.

## Test plan

- Added a unit test for the empty-channel case
- Ran `eas update --channel <empty channel>` against a real project: the old CLI fails with the error above, this build creates the branch and publishes the update
2026-06-24 09:18:11 -07:00
Ash Wu 4071f4834c [build-tools] add metadata to eas/upload_artifact (#3878) 2026-06-22 17:59:44 +08:00
Ash Wu 55fe28cf63 [build-tools] upload maestro screenshots (#3872)
Signed-off-by: Ash Wu <[email protected]>
2026-06-22 17:13:29 +08:00
Stanisław Chmiela 88a404e45f [build-tools] Skip Xcode selection in development (#3884)
This was needed for running in development where `sudo` is NOT passwordless and waits for password input.

## Summary
- skip `sudo xcode-select` for remote session startup when `ENVIRONMENT=development`
- centralize Xcode developer directory selection for agent-device, Argent, and serve-sim remote sessions
- run `sudo xcode-select` with stdin ignored and stdout/stderr piped to the step logger outside development

## Test plan
- yarn workspace @expo/build-tools test src/steps/utils/__tests__/remoteDeviceRunSession.test.ts
- yarn workspace @expo/build-tools typecheck
2026-06-19 14:15:18 +02:00
Stanisław Chmiela cb2daddbfc [build-tools] Fix Argent artifact upload size (#3883)
Since https://github.com/software-mansion/argent/pull/347 Argent exposes a `GET /artifacts/` endpoint which returns a `size` for every artifact. This `size` is then being used to create a `www` upload session (which validates and includes the `size` as `Content-Length` of the signed upload URL).

The `size` is being calculated in Argent as `fs.stat()` which works great for single files, but for directories it returns 96 bytes. The actual archiving of a given directory (producing an artifact) is only happening when downloading the artifact with `GET /artifacts/:id`.

This pull request changes the artifacts behavior around Argent from:
- list artifacts
- trust `size`
- pipe `GET /artifacts/:id` directly to signed upload URL

to:
- list artifacts
- download from `GET /artifacts/:id` to temporary directory
- check the `size` of the actually downloaded file
- pipe it to signed upload URL.

## Test

You can see which traces have been created _with_ the fix and which _without_ it.

<img width="445" height="354" alt="Zrzut ekranu 2026-06-19 o 13 03 01" src="https://github.com/user-attachments/assets/fddecddf-419c-46a1-8876-599bc00ea8ce" />
2026-06-19 12:02:16 +00:00
Stanisław Chmiela b2388fdfa0 [build-tools] Add Argent remote session artifact uploads (#3880)
## Summary
- bump the minimum supported Argent remote-session version to the latest published `@swmansion/argent` version (`0.12.0`)
- enable Argent's artifact inventory endpoint before starting the tool-server
- poll the local Argent tool-server every 5 seconds for new artifacts and stream each new artifact to the EAS device-run-session artifact upload backend
- add focused tests for version validation, artifact inventory listing, and signed upload streaming

## Testing

CI should pass.
2026-06-19 09:40:58 +02:00
Douglas Lowder 0986682159 [eas-cli] Add observe:session for inspecting events by session ID (#3868)
# Why

New command for retrieving and analyzing events in a session.

```
USAGE
  $ eas observe:session SESSIONID [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  SESSIONID  Session ID to inspect

FLAGS
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --non-interactive     Run the command in non-interactive mode.
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)

DESCRIPTION
  display the timeline of metric and log events for a specific session

```

# How

Query both metrics and logs, filtered by the session ID passed in. 

Example results:

```
# Results for an app startup session (main session)
easd observe:session 2bb44b16-7650-4efd-8ec8-a1bd75ee81dc

App version: 1.1.0 (31)
Device:      Pixel 7 · Android 13
First seen:  May 11, 2026, 04:12:07.662 AM
Last seen:   May 11, 2026, 04:13:04.886 AM

Offset  Type    Name          Value  Properties         Severity
------  ------  ------------  -----  -----------------  --------
0.00s   metric  Bundle Load   0.77s  -                  -       
0.05s   log     app.open      -      cold_start=false   INFO    
0.15s   metric  Startup TTR   1.38s  -                  -       
0.45s   metric  Startup TTI   2.16s  -                  -       
0.55s   metric  Warm Launch   1.29s  -                  -       
0.95s   log     screen.view   -      screen=profile     INFO    
17.16s  log     habit.logged  -      habit_id=meditate  INFO    
                                     streak_days=12             
56.59s  log     screen.view   -      screen=streak      INFO    
57.22s  log     screen.view   -      screen=paywall     INFO

# Results for a navigation session with routes
$ eas observe:session 7e57f6cd-f8e9-4fe0-b6a5-934856d61663

App version: 1.1.0 (31)
Device:      iPhone16,1 · iOS 17.5
First seen:  May 6, 2026, 06:54:35.820 AM
Last seen:   May 6, 2026, 06:56:11.753 AM

Offset  Type    Name                           Value / Severity
------  ------  -----------------------------  ----------------
0.00s   metric  Nav Cold TTR · /task/[taskId]  0.51s           
0.08s   metric  Nav TTI · /task/[taskId]       0.53s           
4.78s   metric  Nav Warm TTR · /stats          0.55s           
4.84s   metric  Nav TTI · /stats               0.57s           
33.66s  metric  Nav Warm TTR · /stats          0.53s           
33.72s  metric  Nav TTI · /stats               0.47s           
53.96s  metric  Nav Warm TTR · /stats          0.24s           
54.02s  metric  Nav TTI · /stats               0.82s           
78.61s  metric  Nav Warm TTR · /new            0.21s           
78.67s  metric  Nav TTI · /new                 0.27s           
95.87s  metric  Nav Warm TTR · /new            0.08s           
95.93s  metric  Nav TTI · /new                 0.20s

```

# Test Plan

- Tested against a project in staging.
- New unit tests added.
2026-06-18 13:19:55 -07:00
Expo CI 24354688b6 update CHANGELOG.md 2026-06-18 17:47:10 +00:00
Expo CI 490457976c v20.3.0 2026-06-18 17:44:26 +00:00
Gabe Debes 787fe4d3a7 [eas-cli] Make integrations:posthog:connect resilient to dynamic app configs (#3877) 2026-06-18 09:54:59 -07:00
Gabe Debes 2dae2cdd0e [eas-cli] Add integrations:posthog:disconnect (#3838) 2026-06-18 09:44:07 -07:00
Gabe Debes 967dff73df [eas-cli] Add integrations:posthog:dashboard (#3837) 2026-06-18 09:29:04 -07:00
Stanisław Chmiela 3a3995817e [build-tools] Fix Argent remote session startup (#3881)
Fixes Argent remote simulator sessions getting stuck immediately after starting the Argent tool-server.

The latest Argent support change switched the startup path to `bunx @swmansion/argent server start ...` and awaited that process. When the server process stays in the foreground, the build step never advances to read the tool-server state file, start the ngrok tunnel, or report remote config back to the API.

This restores detached process launching for the Argent tool-server while keeping the new server/auth flow. If the state file is not created in time, the error now includes captured Argent process output to aid diagnosis.

Failing job run referenced while debugging: https://expo.dev/accounts/sjchmiela/projects/test-production/job-runs/019eda48-6467-789d-8c5e-a7ed5f4ec89d
2026-06-18 18:26:47 +02:00
Gabe Debes 8a392544f2 [eas-cli] Add integrations:posthog:connect (#3836)
## Why

Connect an Expo app to PostHog in one command. It provisions the PostHog project, asks which features you want, installs the SDK, and writes the env vars — so analytics / session replay / error tracking work after a build.

## How

Fast-forward order — provision first, then ask what to wire up:

1. **Provision** — reuses your account's PostHog org + this app's project, or creates them. Only a brand-new connection asks a question: **region** (US / EU), which sets data residency and can't be changed later.
2. **Pick features** — a multiselect, all preselected: Analytics, Session replay, Error tracking (source maps, _requires a personal API key_).
3. **Error tracking, if selected** — prompts you to paste a PostHog personal API key (we can't mint it — you create it in PostHog and paste it in).

It then:

- **Installs** `posthog-react-native` (+ `expo-file-system`, `expo-application`, `expo-device`, `expo-localization`; plus `posthog-react-native-session-replay` when replay is on) via `npx expo install`
- **Adds** the `posthog-react-native/expo` config plugin (prints manual steps if your app config is dynamic)
- **Writes env vars** to `.env.local` (local dev) and EAS (Production / Preview / Development):
  - Analytics → `EXPO_PUBLIC_POSTHOG_API_KEY`, `EXPO_PUBLIC_POSTHOG_HOST` (public)
  - Error tracking → `POSTHOG_CLI_API_KEY` (sensitive), `POSTHOG_CLI_PROJECT_ID`, `POSTHOG_CLI_HOST` (public)
- Source-map uploads on builds are covered by our guide; the command just sets the `POSTHOG_CLI_*` vars.

This PR also includes the PostHog GraphQL layer the command builds on — `PostHogQuery` / `PostHogMutation`, the connection/project types, and `commandUtils/posthog` (dashboard URL).

**Flags:** `--region`, `--session-replay` / `--no-session-replay`, `--error-tracking` / `--no-error-tracking`, `--posthog-cli-api-key`, `--overwrite`, plus `--json` / `--non-interactive`. Agent-ready: error tracking auto-skips without a key (or pass `--posthog-cli-api-key`); `--error-tracking` without a key fails before provisioning; `--region` is required non-interactively.

## Test Plan

- CI — 34 unit tests, full coverage of `connect.ts`
- Ran end-to-end against production: provisioned a PostHog project, selected all features, and confirmed the env vars land in `.env.local` + EAS and the SDK packages / config plugin are added

<img width="2048" height="2718" alt="carbon (1)" src="https://github.com/user-attachments/assets/073a5c41-7f07-48d6-b5b7-b1de3cff89fa" />
2026-06-18 09:20:28 -07:00
Stanisław Chmiela 84015ebe32 [build-tools] Upgrade Argent support (#3879)
- v0.11 of Argent added official support for "remote mode", i.e. we don't need to start the MCP server for _it_ to start the tools-server, we can just start the tools-server.
- just to be sure I've added a min Argent version check.
- new Argent versions also guard the tools server by authorization, so added `authToken` support
- new Argent versions also check `Host` header so made ngrok rewrite it to `localhost:...` to fix error below

<img width="868" height="538" alt="Zrzut ekranu 2026-06-18 o 10 59 54" src="https://github.com/user-attachments/assets/6b3689a4-9359-4f8f-bba7-4e98e97ff0cd" />
2026-06-18 12:13:26 +02:00
Jeff C 7a38191692 [eas-cli] Fix update:embedded topic description (#3866)
* [eas-cli] Fix update:embedded topic description

Without an explicit topic entry, oclif derived the "update:embedded"
group description from a subcommand, so the topic listing showed
"delete an embedded update registered with EAS Update" — confusingly
describing only the delete subcommand. Add an explicit topic description.

* [eas-cli] Add changelog entry for update:embedded topic fix
2026-06-17 11:42:34 -07:00
Keith KurakandClaude Opus 4.8 3e393650dc [eas-cli] Add account:audit command to view account audit logs (#3863)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 14:58:44 -04:00
Keith KurakandClaude Opus 4.8 5e2ecb5997 [eas-cli] Support platform-specific update IDs in update:view (#3864)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 11:41:39 -04:00
Will Schurman 🦦 51aceb1db0 [eas-cli] Simplify 2FA now that SMS is no longer supported (#3859) 2026-06-15 16:22:07 -07:00
Expo CI 90f78ab2ae update CHANGELOG.md 2026-06-15 20:16:07 +00:00
Expo CI 203e9997ee v20.2.0 2026-06-15 20:13:05 +00:00
Quinlan Jung 🦥andClaude Opus 4.8 792af28a5d [eas-cli] add support for non-interactive update:rollback (#3825)
* [eas-cli] add support for non-interactive update:rollback

* [eas-cli] Add CHANGELOG entry for non-interactive update:rollback

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 13:02:37 -07:00
Mustafa Abban e62bb1a840 [worker] Resolve runtime cache settings from job env and fallback on npm cache failures (#3855)
* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* no longer url fallback

* use env over runtime stngs

* report log to sentry only

* proper subclass errs

* proper subclass errs

* proper subclass errs

* runtime setting override, rem set registry cmd

* continue support DISABLE flags, in line err tracker
2026-06-15 13:46:25 -04:00
Stanisław Chmiela f8c2b2daf4 [worker] Remove legacy upload code, add retries, auto-waive builds (#3848)
## What changed

- Removed `gcsSignedUploadUrlForApplicationArchive` and `gcsSignedUploadUrlForBuildArtifacts` from worker runtime config and runtime config typing.
- Removed launcher signed-upload fallback behavior for managed application archive and build artifacts uploads.
- Classified managed artifact upload failures as `SystemError`s with internal tracking codes while keeping the public error code as `SERVER_ERROR`.
- Added retry options to worker upload API `turtleFetch` calls for upload-session creation and artifact metadata saving.

## Why

These signed upload URL fields are no longer expected, so keeping fallback paths made the worker handle an obsolete runtime contract. Upload failures in these managed paths are infrastructure/API/storage failures rather than user-actionable project issues.

## Validation

- `yarn oxfmt packages/worker/src/config.ts packages/worker/src/upload.ts packages/worker/src/external/turtle.ts packages/worker/src/__unit__/upload.test.ts`
- `yarn workspace @expo/worker test:unit src/__unit__/upload.test.ts --runInBand`
- `yarn workspace @expo/worker typecheck`
2026-06-15 17:38:41 +02:00
Mustafa Abban 22fc4da732 [build-tools] Enable Gradle cache via machine-level gradle.properties to avoid fingerprint mutation (#3852) 2026-06-12 09:34:58 -04:00
Stanisław Chmiela 1c7c79d445 [build-tools] Drop legacy cache URL fallbacks (#3856)
## Summary

Drops the temporary legacy cache URL fallback path now that the worker env-var PR is deployed.

This removes the fallback URL state from runtime settings, removes the old cache URL fields from worker runtime config parsing/types, and removes the now-obsolete config fallback test.

## Impact

Cache URL sourcing now relies on the runtime-settings cache gates plus the new worker cache URL environment variables only. Public build env names remain unchanged.

## Validation

CI should pass. Once deployed to staging I'll confirm the env vars and cache config is there.
2026-06-12 10:06:16 +02:00
Szymon Świerk b30dd9054d [eas-cli] Validate/regenerate/create provisioning profile in non-interactive iOS builds using submission ASC API key when present (#3805)
# Why

Many projects already configure an App Store Connect API key in EAS credentials for submissions, but non-interactive iOS App Store and Enterprise builds (`eas build --non-interactive`) could not use that key when validating or repairing provisioning profiles on Apple servers. Instead, CI had to provide `EXPO_ASC_*` environment variables separately, even when a usable key was already stored on EAS.

This makes non-interactive credential setup more convenient by reusing the EAS-stored submission ASC API key for App Store and Enterprise profile validation and repair.

# How

- Added `tryAuthenticateAppStoreWithEasAscApiKeyAsync` in `AscApiKeyUtils.ts` — a best-effort, non-throwing helper that authenticates `ctx.appStore` in non-interactive mode using:
  1. `EXPO_ASC_API_KEY_PATH` / `EXPO_ASC_KEY_ID` / `EXPO_ASC_ISSUER_ID` environment variables, or
  2. The App Store Connect API key configured for submissions on the app in EAS credentials.
- In `SetUpProvisioningProfile.runAsync`, call this helper before Apple-side validation when running non-interactively without an existing auth context.
- Use actual authentication state (`ctx.appStore.authCtx`) instead of startup `defaultAuthenticationMode` when deciding whether Apple authentication is available in `SetUpProvisioningProfile`, `CreateProvisioningProfile`, and `ConfigureProvisioningProfile`.
- In non-interactive mode, treat unexpected Apple validation errors as best-effort: log a warning and continue rather than failing the build.
- Extracted shared `resolveAscApiKeyForAppCredentialsAsync` (previously duplicated in `SetUpAdhocProvisioningProfile`) for reuse across credential flows.
- Derive the `AppleTeamType` for profile operations from the distribution type (`IN_HOUSE` for enterprise, `COMPANY_OR_ORGANIZATION` otherwise), with `EXPO_APPLE_TEAM_TYPE` override.
- Updated error messages to mention both `EXPO_ASC_*` environment variables and EAS-stored ASC API keys.

Out of scope: ad-hoc provisioning profile refresh (opt-in, handled separately via `--refresh-ad-hoc-provisioning-profile`).

# Test Plan

- Added unit tests for auto-authentication, team type resolution, best-effort validation skip, and missing-auth paths in `SetUpProvisioningProfile-test.ts`.
- Tested manually:
  - Run an interactive iOS production build and create a provisioning profile
  - Confirm non-interactive build works with that profile
  - Revoke the provisioning profile in the Apple Developer portal
  - Confirm non-interactive build succeeds and the profile is repaired when the submission ASC API key is configured in app credentials (`easd build --platform ios --profile production --non-interactive`)
2026-06-11 19:12:58 +02:00
Szymon DziedzicandClaude Opus 4.7 7af74c605e [eas-cli] Add --max-duration-minutes flag to eas simulator:start (#3806)
* [eas-cli] Regenerate GraphQL schema

* [eas-cli] Add --max-duration-minutes flag to eas simulator:start

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

* [eas-cli] Only reject negative --max-duration-minutes in simulator:start

Leave the upper-bound check to the server instead of capping it client-side.

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

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-11 16:58:14 +02:00
Szymon DziedzicandClaude Opus 4.8 a5fccedf4e build-tools: fetch Cloudflare TURN ICE servers from www and pass to serve-sim (#3828)
When launching serve-sim, the worker requests short-lived TURN ICE servers from www's turtle-job-runs route (same robot-token pattern as the project clone URL) and forwards them as --stun-url/--turn-url/--turn-username/--turn-credential. Covers all three serve-sim launchers (serve_sim, agent_device, argent).

Best-effort: on any failure serve-sim falls back to its built-in P2P/STUN behavior, so a TURN outage never blocks a session.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 16:43:24 +02:00
Ash Wu f1aa26d9f3 [build-tools] read JUnit new file attribute for Maestro 2.6+ (#3839)
Signed-off-by: Ash Wu <[email protected]>
2026-06-11 20:59:27 +08:00
Szymon Świerk 68a0847058 [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually (#3842)
* [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually

* more precise error handling (make use of UserError/SystemError)
2026-06-11 14:15:13 +02:00
Stanisław Chmiela 1431a20b86 Log user-provided custom functions during parsing (#3847)
I want to learn how much are custom functions being used.
2026-06-11 10:40:49 +02:00
Szymon Dziedzic d2ed1feb67 Speed up agent-device session startup (#3841) 2026-06-10 14:29:55 +02:00
Stanisław Chmiela cc49343b39 [worker] Use runtime settings from storage (#3801)
<!-- 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

https://github.com/expo/workflow-orchestration/pull/851

# How

Added `RuntimeSettings` namespace in `build-tools`. It is expected to be called in the beginning with `loadAsync` to fetch data from the bucket.

# Test Plan

Once approved deploy to staging, run a few builds with different settings.
2026-06-10 12:21:53 +02:00
Ash Wu 10f9edc910 [build-tools] fix getMaestroVersion parsing error (#3834)
Signed-off-by: Ash Wu <[email protected]>
2026-06-10 16:21:51 +08:00
Stanisław Chmiela 9d1a6b7796 [scripts] Add runtime settings editor (#3812)
I want us to move off of killswitches in Redis coming from `workflow-orchestration` towards fetching killswitches from a simple JSON file.

I picked GCS over R2 because it lets us make objects fully public (no `Authorization` required) and I (Stanley) found no way to achieve the same thing with R2.

This pull request adds a script `scripts/edit-runtime-settings staging | production` that is going to let us edit the file.
2026-06-09 14:14:50 +02:00
Gabe Debes d8cca2c8e8 [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4 (#3824)
* [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4

* update CHANGELOG.md

* Add manual worker build workflow

* Add manual worker build workflow

* tmp: trigger worker build on branch push

* Trigger worker build on PRs touching worker or cocoapods-proxy

* Print job statuses on EAS workflow success

* Remove manual worker build workflow
2026-06-08 08:59:27 -07:00
Kudo Chien fb818fce54 [repack] add ios signing entitlements params (#3803) 2026-06-08 22:52:07 +08:00
Ash Wu 11263c23a0 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output (#3818)
* [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-06-08 21:04:27 +08:00
Ash Wu 80baa13ffa [build-tools] track installed maestro cli version (#3822)
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 15:05:54 +08:00
Expo CI 58982ee212 update CHANGELOG.md 2026-06-05 21:39:01 +00:00
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
254 changed files with 29056 additions and 3129 deletions

No files matched your search

+5 -5
View File
@@ -12,9 +12,9 @@ jobs:
contents: read
pull-requests: write
steps:
# 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
# Pinned to a commit SHA (not a tag or branch) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag or
# moving branch would allow unreviewed upstream code to run here.
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
githubToken: ${{ secrets.GITHUB_TOKEN }}
+2 -1
View File
@@ -5,5 +5,6 @@
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
}
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
+140 -2
View File
@@ -8,14 +8,152 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
### 🎉 New features
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
### 🎉 New features
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
### 🐛 Bug fixes
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
### 🎉 New features
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [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))
## [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))
### 🧹 Chores
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.13.1",
"version": "20.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2
View File
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.13.1",
"version": "20.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,19 +37,20 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.13.1",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@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.13.1",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@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",
@@ -76,7 +77,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.4.1",
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -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,407 @@
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',
target: 'build',
},
},
],
},
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.maestro.install', 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.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
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',
target: 'build',
},
},
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',
target: 'job_run',
},
},
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: { target: 'build' },
},
],
},
})
);
});
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.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
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: { target: 'build' },
},
},
})
);
});
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: { target: 'job_run' },
},
},
})
);
});
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,329 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
jest.mocked(fetch).mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'production' });
await RuntimeSettings.loadAsync({ environment: 'test' });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('returns null when job env enables cache but worker env var is empty', async () => {
process.env.EAS_NPM_CACHE_URL = '';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use cache URLs from the job environment', async () => {
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
EAS_NPM_CACHE_URL: 'https://job-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
},
});
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
});
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '0',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging' });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
+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');
}
@@ -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();
});
});
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
@@ -10,6 +10,8 @@ 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';
@@ -30,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';
@@ -180,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);
});
@@ -192,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.');
+32 -1
View File
@@ -1,5 +1,11 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -81,6 +88,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
@@ -103,3 +111,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
+25 -6
View File
@@ -16,6 +16,7 @@ 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';
@@ -24,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';
@@ -173,17 +175,28 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
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);
@@ -197,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.');
@@ -1,4 +1,4 @@
import { BuildJob } from '@expo/eas-build-job';
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
@@ -69,4 +69,131 @@ describe('easBuildInternal', () => {
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);
});
});
@@ -0,0 +1,256 @@
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to install dependencies using npm cache registry',
name: 'NpmCacheRegistryInstallError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
status: 1,
signal: null,
},
}
);
});
it('reports non-fatal npm cache registry errors from successful installs', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
options.lineTransformer(auditErrorLine);
options.lineTransformer(
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
);
return createSpawnPromise(Promise.resolve(createSpawnResult()));
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Non-fatal npm cache registry error during dependency install',
name: 'NpmCacheRegistryNonFatalError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
firstErrorLine: auditErrorLine,
errorLineCount: 1,
},
}
);
});
it('does not retry when install failure does not reference npm cache registry', async () => {
const logger = createMockLogger();
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: 'npm error command sh -c node postinstall.js',
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -1,4 +1,12 @@
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import {
BuildJob,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -42,6 +50,11 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
try {
const result = await spawn(
cmd,
@@ -53,6 +66,7 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
@@ -138,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);
@@ -1,8 +1,9 @@
import { Job } from '@expo/eas-build-job';
import { Job, SystemError } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
env,
logger,
infoCallbackFn,
lineTransformer,
cwd,
useFrozenLockfile,
}: {
@@ -19,6 +21,7 @@ export async function installDependenciesAsync({
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
lineTransformer?: SpawnOptions['lineTransformer'];
useFrozenLockfile: boolean;
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
let args: string[];
@@ -69,11 +72,108 @@ export async function installDependenciesAsync({
cwd,
logger,
infoCallbackFn,
lineTransformer,
env,
}),
};
}
export async function installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
}: {
packageManager: PackageManager;
env: Record<string, string | undefined>;
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<void> {
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
if (!npmCacheUrl) {
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
return;
}
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
let firstErrorLine: string | undefined;
let errorLineCount = 0;
try {
await (
await installDependenciesAsync({
packageManager,
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
logger,
infoCallbackFn,
lineTransformer: (line: string) => {
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
firstErrorLine ??= line;
errorLineCount += 1;
}
return line;
},
cwd,
useFrozenLockfile,
})
).spawnPromise;
if (firstErrorLine) {
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
level: 'warning',
tags: { packageManager },
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
});
}
} catch (err: unknown) {
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
throw err;
}
logger.warn(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(err), {
level: 'warning',
tags: { packageManager },
extras: {
cwd,
npmCacheUrl,
useFrozenLockfile,
originalErrorMessage: err instanceof Error ? err.message : String(err),
status: err instanceof Error ? (err as any).status : undefined,
signal: err instanceof Error ? (err as any).signal : undefined,
},
});
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
}
}
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
@@ -87,3 +187,36 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
}
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+12 -12
View File
@@ -2,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { SpawnOptions } from '@expo/turtle-spawn';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import {
installDependenciesWithNpmCacheFallbackAsync,
resolvePackagerDir,
} from './installDependencies';
import { BuildContext } from '../context';
import { runExpoCliCommand } from '../utils/project';
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
options: spawnOptions,
packageManager: ctx.packageManager,
});
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
})
).spawnPromise;
await installDependenciesSpawnPromise;
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
});
}
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
}
`),
{
buildId: ctx.env.EAS_BUILD_ID,
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
projectMetadataFile: {
type: 'GCS',
bucketKey: uploadSession.bucketKey,
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+11 -7
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';
@@ -45,6 +45,7 @@ export type ArtifactToUpload =
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
};
export interface BuildContextOptions {
@@ -62,9 +63,8 @@ export interface BuildContextOptions {
reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -80,7 +80,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
@@ -97,7 +97,6 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -113,7 +112,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 = {
@@ -354,7 +352,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(
+97
View File
@@ -0,0 +1,97 @@
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>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
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: withTargetTag(tags, target),
},
];
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 tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
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);
},
};
+5
View File
@@ -20,6 +20,11 @@ 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';
export * from './runtimeSettings';
@@ -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
@@ -26,10 +26,12 @@ 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')]: '',
@@ -57,6 +59,9 @@ function makeIosBuildContext({
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(),
@@ -126,14 +131,14 @@ describe(installPods, () => {
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
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\.17; not enabling precompiled modules use because precompiled modules require expo>=/
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
)
);
});
@@ -176,4 +181,23 @@ describe(installPods, () => {
).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();
});
});
+2 -2
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
@@ -93,7 +93,7 @@ export async function runFastlane(
cwd,
}: {
logger?: bunyan;
env?: Record<string, string>;
env?: Env;
cwd?: string;
} = {}
): Promise<SpawnResult> {
+7 -2
View File
@@ -5,7 +5,7 @@ import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
@@ -46,7 +46,12 @@ export async function installPods<TJob extends Ios.Job>(
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
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.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
+138
View File
@@ -0,0 +1,138 @@
import { Env } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let runtimeEnvironment: Env = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
env: nextRuntimeEnvironment = {},
}: {
environment: string;
env?: Env;
}): Promise<void> {
runtimeEnvironment = nextRuntimeEnvironment;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
} catch (err) {
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
}
export function getNodeJsCacheUrl(): string | null {
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
}
export function getMavenCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
}
export function getCocoapodsCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
}
}
@@ -41,6 +41,7 @@ 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';
@@ -60,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createDownloadBuildFunction(ctx),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -81,6 +82,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -96,7 +98,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
createMaestroTestsBuildFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -1,4 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
applicationArchiveUrl,
error,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
data: {
builds: {
byId: {
id: randomUUID(),
platform: 'IOS',
artifacts: {
applicationArchiveUrl: applicationArchiveUrl ?? null,
},
},
},
},
}
);
return {
query: jest.fn().mockReturnValue({ toPromise }),
} as unknown as Client;
}
describe('downloadBuild', () => {
it('should handle an archive', async () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(artifactPath).toBeDefined();
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: applicationArchiveUrl,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
message: 'Build does not have an application archive url',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws UserError when GraphQL returns a client error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Build not found' }],
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
})
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
describe('createDownloadBuildFunction', () => {
it('should download a build', async () => {
const downloadBuild = createDownloadBuildFunction();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
const logger = createMockLogger();
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
}),
{
callInputs: {
build_id: randomUUID(),
build_id: buildId,
extensions: ['app'],
},
}
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
} as unknown as Response);
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
});
});
@@ -0,0 +1,90 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -0,0 +1,132 @@
import { BuildStepContext, BuildStepEnv } from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
PackageManager,
findPackagerRootDir,
resolvePackageManager,
shouldUseFrozenLockfile,
} from '../../../utils/packageManager';
import { readPackageJson } from '../../../utils/project';
import { installNodeModules } from '../installNodeModules';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
findPackagerRootDir: jest.fn(),
shouldUseFrozenLockfile: jest.fn(),
}));
jest.mock('../../../utils/project', () => ({
...jest.requireActual('../../../utils/project'),
readPackageJson: jest.fn(),
}));
describe(installNodeModules, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(findPackagerRootDir).mockReturnValue('/tmp/build');
jest.mocked(shouldUseFrozenLockfile).mockReturnValue(false);
jest.mocked(readPackageJson).mockReturnValue({});
});
function createStepCtx(logger: ReturnType<typeof createMockLogger>): BuildStepContext {
return {
logger,
workingDirectory: '/tmp/build',
global: {
projectTargetDirectory: '/tmp/build',
staticContext: { metadata: {} },
},
} as unknown as BuildStepContext;
}
it('retries the install without the npm cache registry when the cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.caches.eas-build.internal';
const env: BuildStepEnv = {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
};
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND`,
});
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installNodeModules(createStepCtx(logger), env);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
})
);
expect(spawn).toHaveBeenNthCalledWith(
2,
'npm',
['install', '--include=dev'],
expect.objectContaining({
env: { EAS_USE_NPM_CACHE: '1', EAS_BUILD_NPM_CACHE_URL: npmCacheUrl },
})
);
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
status: 1,
signal: null,
stdout: '',
stderr: 'npm error some unrelated failure',
});
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(installNodeModules(createStepCtx(logger), {})).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -4,10 +4,14 @@ import { vol } from 'memfs';
import {
copyLatestAttemptXml,
isFileAttrRun,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
@@ -298,7 +302,169 @@ describe(parseMaestroResults, () => {
});
});
describe(parseMaestroResultsFromFileAttrs, () => {
it('uses file= as the path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results[0]).toEqual(
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
);
});
it('keeps two same-named flows separate by their file= path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('returns per-attempt results keyed by file= across attempt files', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/junit/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
]);
});
it('returns empty array when the directory does not exist', async () => {
const results = await parseMaestroResultsFromFileAttrs('/nope');
expect(results).toEqual([]);
});
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
});
it('returns false when any testcase lacks file= (mixed report)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when file= is an empty string', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false for a legacy report with no file= attributes', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when the file cannot be read', async () => {
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
});
});
describe('isFileAttrRun', () => {
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
expect(isFileAttrRun([])).toBe(false);
});
});
describe(parseJUnitTestCases, () => {
it('extracts the file= attribute when present', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].file).toBe('.maestro/login.yaml');
});
it('treats a missing or empty file= attribute as undefined', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
});
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -582,6 +748,100 @@ describe(parseJUnitTestCases, () => {
});
});
describe(parseFailedFlowsFromFileAttrs, () => {
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/proj/b/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['b/login.yaml']);
});
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/flows/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['flows/login.yaml']);
});
it('resolves and existence-checks an absolute file= path', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/outside/flow.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['/outside/flow.yaml']);
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
@@ -765,6 +1025,28 @@ describe('mergeJUnitReports', () => {
expect(b.failure).toBeUndefined();
});
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.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 files = testcases.map((t: any) => t['@_file']).sort();
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
});
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
@@ -0,0 +1,287 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
parseFailureScreenshotFilename,
selectFailureScreenshots,
} from '../maestroScreenshots';
describe(parseFailureScreenshotFilename, () => {
it('parses a plain failure screenshot', () => {
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('parses a sharded failure screenshot', () => {
expect(
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('handles parentheses inside the flow name (right-to-left parse)', () => {
expect(
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
).toEqual({
flowName: 'Login (staging) Flow',
capturedAtMs: 1781186692250,
});
});
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
expect(
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
).toBeNull();
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
});
});
describe(harvestFailureScreenshotsAsync, () => {
const logger = createMockLogger();
let testsDirectory: string;
beforeEach(async () => {
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
});
afterEach(async () => {
await fs.rm(testsDirectory, { recursive: true, force: true });
});
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
const dir = path.join(testsDirectory, name);
await fs.mkdir(dir);
for (const file of files) {
await fs.writeFile(path.join(dir, file), '');
}
const when = new Date(mtimeMs);
await fs.utimes(dir, when, when);
}
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'attempt-dir',
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([
{
fileAbsPath: path.join(
testsDirectory,
'attempt-dir',
'screenshot-❌-1781186692250-(Login Flow).png'
),
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login Flow',
attemptIndex: 1,
capturedAtMs: 1781186692250,
},
},
]);
});
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'old-dir',
['screenshot-❌-1781186692250-(Old Flow).png'],
sinceMtimeMs - 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'dir-a',
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
sinceMtimeMs + 1_000
);
await makeDebugDir(
'dir-b',
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
sinceMtimeMs + 2_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
});
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'sharded-dir',
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
sinceMtimeMs + 1_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
});
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
const sinceMtimeMs = 1_000_000;
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
await makeDebugDir(
'touched-dir',
['screenshot-❌-999000-(Stale Flow).png'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([]);
});
it('logs and returns [] when the tests dir cannot be read', async () => {
const shots = await harvestFailureScreenshotsAsync({
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
capturedSinceMs: 0,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
});
describe(computePureFailureFlowNames, () => {
it('classifies a flow that failed every attempt as pure', () => {
const pure = computePureFailureFlowNames([
{ name: 'Login Flow', status: 'failed' },
{ name: 'Login Flow', status: 'failed' },
]);
expect([...pure]).toEqual(['Login Flow']);
});
it('excludes a flaky flow (failed then passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'Flaky Flow', status: 'failed' },
{ name: 'Flaky Flow', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
const pure = computePureFailureFlowNames([
{ name: 'FPF', status: 'failed' },
{ name: 'FPF', status: 'passed' },
{ name: 'FPF', status: 'failed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a flow that only passed', () => {
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
});
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
expect([...pure]).toEqual(['sub_Login']);
});
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'sub/Login', status: 'failed' },
{ name: 'sub_Login', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('returns an empty set for no testcases', () => {
expect(computePureFailureFlowNames([]).size).toBe(0);
});
});
describe(selectFailureScreenshots, () => {
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
return {
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1_000 + attemptIndex,
},
};
}
it('keeps only the final attempt for a pure-failure flow', () => {
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
});
it('keeps every attempt for a flow not classified pure (flaky)', () => {
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
const selected = selectFailureScreenshots(shots, new Set());
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
});
it('reduces pure flows while preserving flaky flows and input order', () => {
const shots = [
makeShot('Pure', 0),
makeShot('Flaky', 0),
makeShot('Pure', 1),
makeShot('Flaky', 1),
];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
['Flaky#0', 'Pure#1', 'Flaky#1']
);
});
it('keeps all when the pure set is empty', () => {
const shots = [makeShot('A', 0), makeShot('A', 1)];
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
});
});
@@ -1,10 +1,15 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import * as maestroScreenshots from '../maestroScreenshots';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
@@ -17,7 +22,29 @@ jest.mock('../../../utils/retry', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
jest.mock('../maestroScreenshots', () => ({
...jest.requireActual('../maestroScreenshots'),
harvestFailureScreenshotsAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
const mockUploadArtifact = jest.fn();
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
return {
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
displayName: 'Failure Screenshot: Login (attempt 1)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login',
attemptIndex: 0,
capturedAtMs: 1781186692250,
},
};
}
const SPAWN_SUCCESS = {
status: 0,
@@ -41,7 +68,10 @@ function createStep(
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
const fn = createMaestroTestsBuildFunction(ctx);
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
@@ -54,10 +84,18 @@ describe('createMaestroTestsBuildFunction', () => {
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
expect(
createMaestroTestsBuildFunction({
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext)
).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
@@ -273,6 +311,33 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const newParseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
.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(newParseSpy).toHaveBeenCalledTimes(1);
// The new branch needs no name→path map: no scan, no legacy parser.
expect(discoverySpy).not.toHaveBeenCalled();
expect(legacyParseSpy).not.toHaveBeenCalled();
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 nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
@@ -293,6 +358,44 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('never scans flow files when all attempts succeed', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await step.executeAsync();
expect(discoverySpy).not.toHaveBeenCalled();
});
it('memoizes the flow scan across retries (legacy reports)', async () => {
// Routing note: the step lands in the legacy arm because the real
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
// the mock fs. Creating that file with file= attrs would flip the branch.
mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const discoverySpy = jest
.spyOn(discovery, 'buildFlowNameToPathMap')
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/a.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Two failed attempts hit the legacy branch twice; the scan runs once.
expect(parseSpy).toHaveBeenCalledTimes(2);
expect(discoverySpy).toHaveBeenCalledTimes(1);
});
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']);
@@ -639,4 +742,149 @@ describe('createMaestroTestsBuildFunction', () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
it('uploads harvested screenshots after the retry loop with metadata', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
})
);
});
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('swallows upload errors without affecting the test verdict', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// The classify throw is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('caps uploads at 30 screenshots (excess dropped)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
name,
file: undefined,
status,
duration: 0,
errorMessage: null,
tags: [],
properties: {},
});
jest
.spyOn(parser, 'parseJUnitTestCases')
.mockResolvedValue([
tc('PureFlow', 'failed'),
tc('FlakyFlow', 'failed'),
tc('FlakyFlow', 'passed'),
]);
const makeShotFor = (
flowName: string,
attemptIndex: number
): maestroScreenshots.HarvestedScreenshot => ({
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1781186692250 + attemptIndex,
},
});
const shots = [
makeShotFor('PureFlow', 0),
makeShotFor('PureFlow', 1),
makeShotFor('FlakyFlow', 0),
makeShotFor('FlakyFlow', 1),
];
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
// Uploads run concurrently, so compare the set, not the order.
const uploaded = mockUploadArtifact.mock.calls
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
.sort();
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
});
});
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
vol.reset();
});
it('should set the output path for successful repack', async () => {
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -38,14 +38,33 @@ describe(createRepackBuildFunction, () => {
{
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app',
output_path: '/path/to/output_app',
source_app_path: '/path/to/source_app.ipa',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
});
it('should rename generated aab output path to apk', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
staticContextContent: {
job: createTestAndroidJob(),
},
}),
{
callInputs: {
platform: 'android',
source_app_path: '/path/to/source_app.aab',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
});
it('should throw for unsupported platforms', async () => {
@@ -3,6 +3,7 @@ import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
@@ -43,15 +44,24 @@ describe(createReportMaestroTestResultsFunction, () => {
} as unknown as Client;
});
// Returns a mock logger whose child() is itself, so step-level log calls can
// be asserted on the returned object.
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
const logger = createMockLogger();
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
logger?: ReturnType<typeof createMockLogger>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
logger: overrides?.logger ?? createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
@@ -105,6 +115,54 @@ describe(createReportMaestroTestResultsFunction, () => {
]);
});
it('reports both flows when two share a name but have distinct file= paths', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
'a/login.yaml',
'b/login.yaml',
]);
});
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
},
});
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(discoverySpy).not.toHaveBeenCalled();
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
}),
});
await createStep().executeAsync();
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
});
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
});
it('sends per-attempt results with same name but different retryCount', async () => {
@@ -0,0 +1,72 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
);
});
});
@@ -0,0 +1,121 @@
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(),
enableAccessibilitySettingsAsync: 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(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('does not enable accessibility settings by default', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
});
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({
device_identifier: 'iPhone 15',
count: 2,
enable_accessibility_settings: true,
}).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-1',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-2',
env: expect.any(Object),
});
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
const enableCallOrder =
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
expect(enableCallOrder).toBeLessThan(startCallOrder);
}
});
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();
});
});
@@ -120,4 +120,44 @@ describe(createUploadArtifactBuildFunction, () => {
const typeInput = buildStep.inputs?.find(input => input.id === 'type')!;
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
type: 'build-artifact',
path: '/',
metadata: { kind: 'screen-recording' },
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.not.objectContaining({ metadata: expect.anything() }),
})
);
});
});
@@ -1,6 +1,7 @@
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { Sentry } from '../../sentry';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
export function createSubmissionEntityFunction(): BuildFunction {
@@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction {
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
Sentry.capture('Failed to create submission entity: missing robot access token');
return;
}
const buildId = inputs.build_id.value;
if (!buildId) {
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
Sentry.capture('Failed to create submission entity: missing build ID', {
extras: {
buildId,
},
});
return;
}
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
@@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
try {
const response = await retryOnDNSFailure(fetch)(
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
new URL('/v2/app-store-submissions/', expoApiServerURL),
{
method: 'POST',
headers: {
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction {
const interpolationContext = stepsCtx.global.getInterpolationContext();
if (!('workflow' in interpolationContext)) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
'No workflow found in the interpolation context.'
);
throw new SystemError('No workflow found in the interpolation context.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
});
}
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
'No robot access token found in the job secrets.'
);
throw new SystemError('No robot access token found in the job secrets.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
});
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
throw new SystemError('Missing Expo API server URL.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
});
}
const workflowRunId = interpolationContext.workflow.id;
@@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const { artifactPath } = await downloadArtifactAsync({
logger,
workflowRunId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
params,
});
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,9 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
@@ -16,6 +18,7 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
export function createDownloadBuildFunction(): BuildFunction {
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
artifacts {
applicationArchiveUrl
}
}
}
}
`);
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'download_build',
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
});
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
});
}
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
}: {
buildId: string;
graphqlClient: Client;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
expoApiServerURL,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
expoApiServerURL: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
{
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
}
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
try {
await uploadApplicationArchivesAsync({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload application archives.`, err);
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
}
try {
await uploadBuildArtifacts({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
firstError ||= err;
}
@@ -15,6 +15,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -123,13 +125,24 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
return maestroVersion.stdout.trim();
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
async function installMaestro({
@@ -1,7 +1,7 @@
import { BuildFunction, BuildStepContext, BuildStepEnv } from '@expo/steps';
import path from 'path';
import { installDependenciesAsync } from '../../common/installDependencies';
import { installDependenciesWithNpmCacheFallbackAsync } from '../../common/installDependencies';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
@@ -63,7 +63,7 @@ export async function installNodeModules(
packageName: 'react-native',
});
const { spawnPromise } = await installDependenciesAsync({
await installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger: stepCtx.logger,
@@ -74,5 +74,4 @@ export async function installNodeModules(
reactNativeVersion,
}),
});
await spawnPromise;
}
@@ -1,3 +1,4 @@
import { asyncResult } from '@expo/results';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
export interface JUnitTestCaseResult {
name: string;
file: string | undefined;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
@@ -32,6 +34,12 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// A `file=` attribute counts as present only when it is a non-empty string.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
return typeof f === 'string' && f.length > 0 ? f : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
@@ -54,6 +62,8 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
continue;
}
const file = fileAttrOf(tc);
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
results.push({ name, file, status, duration, errorMessage, tags, properties });
}
}
} catch {
@@ -131,6 +141,142 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return perFile.flat();
}
// ---------------------------------------------------------------------------
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
// its flow's own path (all-or-none per run). Callers check the report first
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
// scan is then never consulted.
// ---------------------------------------------------------------------------
export function isFileAttrRun(
testcases: JUnitTestCaseResult[]
): testcases is (JUnitTestCaseResult & { file: string })[] {
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
}
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
// legacy path, whose own guards degrade safely.
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
return isFileAttrRun(await parseJUnitFile(junitFile));
}
async function fileExists(absPath: string): Promise<boolean> {
return (await asyncResult(fs.stat(absPath))).ok;
}
// Group by `file=` so two same-named flows in different files stay separate.
export async function parseMaestroResultsFromFileAttrs(
junitDirectory: string
): Promise<MaestroFlowResult[]> {
interface JUnitEntry {
result: JUnitTestCaseResult;
sourceFile: string;
}
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
const entries: JUnitEntry[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
entries.push({ result, sourceFile: xmlFile });
}
}
const byPath = new Map<string, JUnitEntry[]>();
for (const entry of entries) {
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
// skip (rather than emit an undefined path) if that contract is violated.
const flowPath = entry.result.file;
if (flowPath === undefined) {
continue;
}
const group = byPath.get(flowPath) ?? [];
group.push(entry);
byPath.set(flowPath, group);
}
const results: MaestroFlowResult[] = [];
for (const [flowPath, group] of byPath) {
// 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 = group
.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);
for (const { result, attemptIndex } of sorted) {
results.push({
name: result.name,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
return results;
}
/**
* Returns the `file=` paths of the failing testcases 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; never the legacy scan).
*
* Callers verify the report carries `file=` attributes first
* (junitFileHasFileAttrs).
*/
export async function parseFailedFlowsFromFileAttrs(args: {
junitFile: string;
workingDirectory: string;
}): Promise<string[] | null> {
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
// truncated XML could drop testcases, and trusting that partial failing set
// would skip retries for cut-off flows.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
const testcases = parseJUnitContent(content);
if (!isFileAttrRun(testcases)) {
return null;
}
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Each failing testcase is self-identifying via `file=`, so duplicate flow
// names retry correctly. path.resolve (not join): `file=` may be absolute
// when the flow lives outside the working directory.
const paths = [...new Set(failing.map(tc => tc.file))];
for (const p of paths) {
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
return null;
}
}
return paths;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -313,6 +459,13 @@ export async function mergeJUnitReports(args: {
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
// different files merge separately. Legacy reports keep name keying.
const allCases = testsuites.flatMap((suite: any) =>
Array.isArray(suite?.testcase) ? suite.testcase : []
);
const useFileKeys =
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
const key = useFileKeys ? fileAttrOf(tc)! : name;
const group = testcasesByName.get(key) ?? [];
group.push(tc);
testcasesByName.set(name, group);
testcasesByName.set(key, group);
}
}
if (testcasesByName.size === 0) {
@@ -0,0 +1,159 @@
import { bunyan } from '@expo/logger';
import { type Dirent } from 'fs';
import fs from 'fs/promises';
import path from 'path';
export interface ParsedFailureScreenshot {
flowName: string;
capturedAtMs: number;
}
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
// `).png` suffix rather than scanning for parens.
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
if (!match) {
return null;
}
const capturedAtMs = Number(match[1]);
if (!Number.isFinite(capturedAtMs)) {
return null;
}
return {
flowName: match[2],
capturedAtMs,
};
}
export interface HarvestedScreenshot {
fileAbsPath: string;
displayName: string; // `Failure Screenshot: <flowName> (attempt <n>)`
metadata: {
kind: 'maestro-test-screenshot';
// The Maestro flow name straight from the filename (already '/'->'_' substituted by
// Maestro). The website matches this against `attempt.name` (normalized the same way).
flowName: string;
attemptIndex: number;
capturedAtMs: number;
};
}
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
// and skipped so a screenshot harvest can't affect the maestro step outcome.
export async function harvestFailureScreenshotsAsync(args: {
testsDirectory: string;
capturedSinceMs: number;
attemptIndex: number;
logger: bunyan;
}): Promise<HarvestedScreenshot[]> {
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
// prevents mis-attributing an earlier attempt's screenshot.
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
let entries: Dirent[];
try {
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
} catch (err: any) {
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
return [];
}
const results: HarvestedScreenshot[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const dirPath = path.join(args.testsDirectory, entry.name);
let filenames: string[];
try {
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
continue;
}
filenames = await fs.readdir(dirPath);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
continue;
}
for (const filename of filenames) {
const parsed = parseFailureScreenshotFilename(filename);
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
// earlier attempt's screenshot to this one.
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
continue;
}
results.push({
fileAbsPath: path.join(dirPath, filename),
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName: parsed.flowName,
attemptIndex: args.attemptIndex,
capturedAtMs: parsed.capturedAtMs,
},
});
}
}
return results;
}
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
// name the same way before comparing — matching the website's own normalization.
function normalizeFlowName(name: string): string {
return name.replace(/\//g, '_');
}
// A flow is a "pure failure" when it failed and never passed across all attempts. The website
// surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
// spend the per-job artifact budget on screenshots that are never shown — we keep only the final
// one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
// NOT pure: each of its failed attempts is a distinct failure and is kept.
export function computePureFailureFlowNames(
testCases: readonly { name: string; status: 'passed' | 'failed' }[]
): Set<string> {
const passed = new Set<string>();
const failed = new Set<string>();
for (const testCase of testCases) {
const flowName = normalizeFlowName(testCase.name);
if (testCase.status === 'passed') {
passed.add(flowName);
} else {
failed.add(flowName);
}
}
const pure = new Set<string>();
for (const flowName of failed) {
if (!passed.has(flowName)) {
pure.add(flowName);
}
}
return pure;
}
// Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
// the final (highest attemptIndex) screenshot.
export function selectFailureScreenshots(
harvested: readonly HarvestedScreenshot[],
pureFailureFlowNames: ReadonlySet<string>
): HarvestedScreenshot[] {
const finalAttemptByPureFlow = new Map<string, number>();
for (const shot of harvested) {
if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
continue;
}
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
}
}
return harvested.filter(shot => {
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
});
}
@@ -1,4 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,15 +9,26 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
} from './maestroResultParser';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
selectFailureScreenshots,
} from './maestroScreenshots';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -79,7 +91,7 @@ function buildMaestroArgs(args: {
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
@@ -200,13 +212,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
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,
});
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// flow scan is built lazily in the retry branch below and memoized so
// retries share one scan. Never runs when the report has `file=`.
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
@@ -214,10 +223,11 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// 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
// flows. The failed-flow parsers return 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 harvested: HarvestedScreenshot[] = [];
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -240,6 +250,8 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
const attemptStartedAtMs = Date.now();
try {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
@@ -259,15 +271,44 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
if (outputFormat === 'junit') {
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
logger,
}))
);
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
let failed: string[] | null;
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
} else {
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
: null;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
@@ -316,6 +357,12 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
}
// 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.
@@ -328,3 +375,82 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
},
});
}
// Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
// artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
// the maestro test result. Caller guards on junit (harvest only runs for junit).
async function uploadFailureScreenshotsAsync({
harvested,
junitReportDirectory,
ctx,
logger,
}: {
harvested: HarvestedScreenshot[];
junitReportDirectory: string;
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
// Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
// attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
// Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
// test verdict (the whole step is verdict-neutral for screenshots).
let selected: HarvestedScreenshot[];
try {
const pureFailureFlowNames = computePureFailureFlowNames(
await parseJUnitTestCases(junitReportDirectory)
);
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
} catch (err: any) {
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
return;
}
if (selected.length === 0) {
return;
}
// Cap well under www's 50-artifact-per-job limit.
const MAX_SCREENSHOT_UPLOADS = 30;
const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
if (selected.length > toUpload.length) {
logger.warn(
`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`
);
}
// Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
// non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
let safeScreenshotDir: string;
try {
safeScreenshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-maestro-screenshots-'));
} catch (err: any) {
logger.warn(
{ err },
'Failed to create the failure-screenshot staging dir; skipping screenshot upload.'
);
return;
}
await Promise.all(
toUpload.map(async (shot, index) => {
try {
// `index` disambiguates two flows that fail within the same millisecond of an attempt.
const safePath = path.join(
safeScreenshotDir,
`failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`
);
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
},
logger,
});
} catch (err: any) {
logger.warn({ err }, 'Failed to upload failure screenshot.');
}
})
);
}
@@ -45,6 +45,7 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -42,11 +42,6 @@ export function createRepackBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'embed_bundle_assets',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -57,6 +52,16 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_use_source_app_entitlements',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_app_entitlements_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -99,9 +104,10 @@ export function createRepackBuildFunction(): BuildFunction {
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
const sourceAppPath = inputs.source_app_path.value as string;
const outputPath =
(inputs.output_path.value as string) ??
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const outputPath = createOutputPath({
sourceAppPath,
tmpDir,
});
const exportEmbedOptions = inputs.embed_bundle_assets.value
? {
sourcemapOutput: undefined,
@@ -138,6 +144,12 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -186,6 +198,21 @@ export function createRepackBuildFunction(): BuildFunction {
});
}
function createOutputPath({
sourceAppPath,
tmpDir,
}: {
sourceAppPath: string;
tmpDir: string;
}): string {
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const extension = path.extname(outputPath);
if (extension.toLowerCase() !== '.aab') {
return outputPath;
}
return `${outputPath.slice(0, -extension.length)}.apk`;
}
/**
* Install `@expo/repack-app` in a sandbox directory and import it.
*/
@@ -275,9 +302,13 @@ export async function resolveAndroidSigningOptionsAsync({
export async function resolveIosSigningOptionsAsync({
job,
logger,
useAppEntitlements,
entitlementsPath,
}: {
job: Job;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
@@ -295,5 +326,7 @@ export async function resolveIosSigningOptionsAsync({
provisioningProfile,
keychainPath: credentials.keychainPath,
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
useAppEntitlements,
entitlementsPath,
};
}
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import {
MaestroFlowResult,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
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, nameToPath);
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
// `file=` attribute; older reports need the legacy flow-file scan to
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
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).'
);
}
}
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
}
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// 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).
// Detect truly conflicting results: the same (path, retryCount) twice means two
// flow files resolved to the same path, which we can't disambiguate and the API
// rejects as a duplicate. Same path 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}`;
const key = `${r.path}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
conflicting.add(r.path);
}
seen.add(key);
}
if (conflicting.size > 0) {
const conflictList = [...conflicting].join(', ');
logger.error(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
usedFileAttrs
? `The same flow file was reported more than once in a single attempt: ` +
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
`or leftover XML files in the JUnit report directory.`
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
`then identified by file path, so duplicate names are fine).`
);
return;
}
@@ -19,6 +19,7 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -205,16 +206,16 @@ export async function restoreGradleCacheAsync({
}
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`
);
// Enable build cache via user-level gradle.properties.
// This avoids mutating android/gradle.properties which affects fingerprinting.
const gradleUserHome = path.join(os.homedir(), '.gradle');
await fs.promises.mkdir(gradleUserHome, { recursive: true });
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
await fs.promises.appendFile(userGradlePropertiesPath, '\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');
// 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(gradleUserHome, 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
@@ -282,22 +283,10 @@ export async function restoreGradleCacheAsync({
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
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');
@@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction {
.filter(key => key !== '');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction {
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
paths,
key,
@@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction {
.filter(path => path.length > 0);
const key = z.string().parse(inputs.key.value);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadPublicCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -1,9 +1,9 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
@@ -12,22 +12,25 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
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 CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -46,10 +49,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the orchestrator-injected
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
// report the remote config back to the API server.
// 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;
@@ -58,61 +64,24 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
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' },
});
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
env,
});
logger.info('Waiting for a public tunnel URL.');
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
@@ -120,7 +89,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -148,60 +118,91 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
logger.info('Launching daemon from cloned agent-device source.');
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
}
async function cloneAgentDeviceAsync({
@@ -220,54 +221,64 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
async function waitForDaemonInfoAsync({
daemonProcess,
}: {
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
daemonProcess: DetachedProcessHandle;
}): Promise<{ port: number; token: string }> {
try {
return await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
} catch (err) {
const output = daemonProcess.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for agent-device daemon credentials.`
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
);
}
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
}
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
try {
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
return result.stdout.trim() || 'unknown';
} catch {
return 'unknown';
}
}
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
'src',
'internal',
'daemon.js'
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
@@ -276,7 +287,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
@@ -0,0 +1,210 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
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 semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({
port: z.number(),
token: z.string().optional(),
});
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;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ 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('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
{ env, logger }
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
const argentServer = spawnDetached({
command: 'bunx',
args: [
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--detach',
],
env,
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
void pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
});
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(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>(() => {});
},
});
}
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bunx resolve it.`
);
return;
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
}
}
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
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;
}
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
defaultValue: 1,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'enable_accessibility_settings',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async ({ logger }, { inputs, env }) => {
try {
@@ -55,16 +61,29 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
| undefined;
const originalDeviceIdentifier =
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
if (!originalDeviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
});
}
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: originalDeviceIdentifier,
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('');
@@ -91,11 +110,23 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: cloneDeviceName,
env,
});
}
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: cloneDeviceName,
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,
@@ -1,15 +1,14 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
ensureBrewPackageInstalledAsync,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
@@ -23,16 +22,14 @@ export function createStartServeSimRemoteSessionBuildFunction(
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 });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -1,4 +1,9 @@
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -65,6 +70,11 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'metadata',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -108,7 +118,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
throw result.reason;
});
const artifact = {
const artifactSpec = {
type: parseArtifactTypeInput({
platform: ctx.job.platform,
inputValue: `${inputs.type.value ?? ''}`,
@@ -116,19 +126,22 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
artifact,
logger,
});
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
if (artifactId) {
outputs.artifact_id.set(artifactId);
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -0,0 +1,111 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
for await (const chunk of stream as Readable) {
void chunk;
}
}
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
describe(listArgentArtifactsAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
});
it('lists Argent artifacts with bearer auth', async () => {
jest.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
artifacts: [
{
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
],
})
)
);
const artifacts = await listArgentArtifactsAsync({
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
});
expect(artifacts).toEqual([
{
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
isDirectory: false,
},
]);
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
headers: { Authorization: 'Bearer tools-token' },
});
});
});
describe(uploadArgentArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
});
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
const data = Buffer.from('artifact-data');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
logger,
artifact: {
id: 'artifact-id',
filename: 'report.json',
mimeType: 'application/json',
},
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
headers: { Authorization: 'Bearer tools-token' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
size: data.length,
stream: expect.anything(),
});
});
});
@@ -0,0 +1,72 @@
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
describe(uploadDeviceRunSessionArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
});
it('streams an artifact through a signed upload URL', async () => {
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
},
},
},
}),
});
const ctx = {
graphqlClient: {
mutation,
},
} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
size: reportedSize,
stream,
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
size: reportedSize,
},
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
expect.objectContaining({
method: 'PUT',
body: stream,
})
);
});
});
@@ -0,0 +1,154 @@
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
} from '../remoteDeviceRunSession';
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
function createCtxMock(): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
},
job: {
secrets: { robotAccessToken: 'robot-token' },
},
} as unknown as CustomBuildContext;
}
function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(turnIceServersToServeSimArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
'turn:turn.cloudflare.com:3478?transport=udp',
'turns:turn.cloudflare.com:443?transport=tcp',
],
username: 'user-123',
credential: 'cred-456',
},
])
).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
'--turn-url',
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'user-123',
'--turn-credential',
'cred-456',
]);
});
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
])
).toEqual([
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
expect(args).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer robot-token' },
})
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
});
@@ -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);
}
@@ -0,0 +1,174 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { sleepAsync } from '../../utils/retry';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const ArgentArtifactSchema = z.object({
id: z.string(),
filename: z.string(),
mimeType: z.string(),
isDirectory: z.boolean().optional(),
});
const ArgentArtifactsListResponseSchema = z.object({
artifacts: z.array(ArgentArtifactSchema),
});
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
export async function pollArgentArtifactsForUploadAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
logger: bunyan;
}
): Promise<never> {
logger.info('Started polling Argent tool-server for artifacts.');
const seenArtifactIds = new Set<string>();
let listArtifactsErrorCount = 0;
for (;;) {
try {
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (seenArtifactIds.has(artifact.id)) {
continue;
}
seenArtifactIds.add(artifact.id);
void uploadArgentArtifactAsync(ctx, {
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload Argent remote session artifact', error);
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
Sentry.capture('Could not list Argent remote session artifacts', error);
logger.warn(
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
'Could not list Argent remote session artifacts.'
);
}
}
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
}
}
export async function listArgentArtifactsAsync({
toolsUrl,
toolsAuthToken,
}: {
toolsUrl: string;
toolsAuthToken?: string;
}): Promise<ArgentArtifact[]> {
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
});
if (!response.ok) {
throw new SystemError(
`Failed to list Argent artifacts: ${response.status} ${response.statusText}`
);
}
const result = ArgentArtifactsListResponseSchema.safeParse(await response.json());
if (!result.success) {
throw new SystemError(`Invalid Argent artifacts response: ${result.error.message}`);
}
return result.data.artifacts;
}
export async function uploadArgentArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
artifact,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
artifact: ArgentArtifact;
logger: bunyan;
}
): Promise<void> {
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
logger.info(`Downloading artifact ${filename}.`);
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
try {
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
await downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath: temporaryArtifactPath,
});
const { size } = await stat(temporaryArtifactPath);
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
size,
stream: createReadStream(temporaryArtifactPath),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath,
}: {
artifact: ArgentArtifact;
toolsUrl: string;
toolsAuthToken?: string;
destinationPath: string;
}): Promise<void> {
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
});
if (!response.ok) {
throw new SystemError(
`Failed to download Argent artifact ${artifact.id}: ${response.status} ${response.statusText}`
);
}
if (!response.body) {
throw new SystemError(
`Argent artifact ${artifact.id} response did not include a readable body.`
);
}
await pipeline(response.body, createWriteStream(destinationPath));
}
@@ -0,0 +1,94 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import fetch, { Headers } from 'node-fetch';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
$deviceRunSessionId: ID!
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
) {
deviceRunSession {
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
uploadSession {
url
headers
}
}
}
}
`);
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
size,
stream,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
size: number;
stream: NodeJS.ReadableStream;
}
): Promise<void> {
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
deviceRunSessionId,
artifactId,
name,
filename,
size,
});
const response = await fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
});
if (!response.ok) {
throw new SystemError(
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
{ cause: response }
);
}
}
async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
size,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
size: number;
}
) {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
input: {
name,
filename,
size,
},
})
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
);
}
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
}
@@ -7,9 +7,9 @@ import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
isCompileStep,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
@@ -195,16 +195,26 @@ const FIXTURE_OVERLAP_WITH_GAP = {
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
buildLogEntries = ['12345-abc.xcactivitylog'] as string[] | Error,
}: {
tempDir?: string;
jsonOutput?: string;
buildLogEntries?: string[] | Error;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'readdir').mockImplementation((async () => {
if (buildLogEntries instanceof Error) {
throw buildLogEntries;
}
return buildLogEntries;
}) as any);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
@@ -224,32 +234,39 @@ describe('parseAndReportXcactivitylog', () => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: false });
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
2,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
{ stdio: 'pipe' }
{ stdio: 'pipe', env: TEST_ENV }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
3,
path.join(tempDir, 'xclogparser'),
[
'parse',
@@ -259,13 +276,16 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
{ stdio: 'pipe' }
{ stdio: 'pipe', env: TEST_ENV }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
@@ -281,6 +301,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
@@ -288,6 +309,7 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -315,6 +337,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found'))
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
@@ -324,6 +347,7 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -353,6 +377,7 @@ describe('parseAndReportXcactivitylog', () => {
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -360,15 +385,16 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
);
});
@@ -382,6 +408,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -389,15 +416,16 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
);
});
@@ -407,6 +435,7 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
@@ -415,15 +444,156 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
{ tags: { phase: 'running_xclogparser' } }
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
);
});
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
const logger = createMockLogger();
mockFilesystem();
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
spawnError.stdout = 'some stdout';
spawnError.status = 1;
spawnError.signal = null;
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(spawnError); // xclogparser run
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.any(Error),
{
tags: { phase: 'running_xclogparser' },
extras: {
exitStatus: 1,
signal: null,
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
stdout: 'some stdout',
},
}
);
});
it('skips with actionable guidance when Logs/Build has no .xcactivitylog files', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: [] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(
`Build performance analysis skipped: no .xcactivitylog files found at ${path.join(
'/tmp/derived-data',
'Logs',
'Build'
)}.`
)
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('derived_data_path("./build")')
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('result_bundle(true)'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('result_bundle_path("./build/result-bundle.xcresult")')
);
});
it('skips when Logs/Build directory does not exist', async () => {
const logger = createMockLogger();
const enoent: any = new Error('ENOENT');
enoent.code = 'ENOENT';
mockFilesystem({ buildLogEntries: enoent });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});
it('captures to Sentry and skips when readdir fails with a non-ENOENT error', async () => {
const logger = createMockLogger();
const eacces: any = new Error('EACCES: permission denied');
eacces.code = 'EACCES';
mockFilesystem({ buildLogEntries: eacces });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "checking_xcactivitylog_existence"',
expect.objectContaining({ code: 'EACCES' }),
expect.objectContaining({
tags: { phase: 'checking_xcactivitylog_existence' },
})
);
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
});
it('ignores non-xcactivitylog entries in Logs/Build when deciding to skip', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: ['LogStoreManifest.plist', 'some-other-file.txt'] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});
@@ -432,6 +602,7 @@ describe('parseAndReportXcactivitylog', () => {
mockFilesystem();
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -439,15 +610,16 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "downloading_xclogparser"',
expect.objectContaining({ message: 'network unreachable' }),
{ tags: { phase: 'downloading_xclogparser' } }
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
);
});
@@ -459,6 +631,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -466,13 +639,174 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: false });
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
describe('xclogparser version resolution', () => {
it('uses preinstalled binary when no version is requested', async () => {
const logger = createMockLogger();
const { reportPath } = mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xclogparser',
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
);
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('downloads default version when no version requested and no preinstall found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
it('uses preinstalled binary when requested version matches', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
});
it('downloads requested version when it does not match the preinstalled binary', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('downloads requested version when no preinstall is found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('falls back to download when the version output cannot be parsed', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
});
});
describe('isCompileStep', () => {
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,6 +1,7 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
@@ -15,42 +16,89 @@ const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Never throws — best-effort observability that does not affect build status.
* Failures route to Sentry via `Sentry.capture` for engineering triage;
* users see only a generic skip message.
* Catches all internal failures (logged + routed to Sentry); returns
* `{ skipped: true }` when analysis did not produce a report so callers can
* mark the build phase skipped.
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
xclogparserVersion,
logger,
proxyBaseUrl,
env,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
}): Promise<void> {
env: BuildStepEnv;
}): Promise<{ skipped: boolean }> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
let phase = 'checking_xcactivitylog_existence';
try {
const logsBuildDir = path.join(derivedDataPath, 'Logs', 'Build');
let buildLogEntries: string[];
try {
buildLogEntries = await fs.readdir(logsBuildDir);
} catch (err: any) {
if (err?.code !== 'ENOENT') {
throw err;
}
buildLogEntries = [];
}
const hasActivityLog = buildLogEntries.some(entry => entry.endsWith('.xcactivitylog'));
if (!hasActivityLog) {
logger.info(
[
`Build performance analysis skipped: no .xcactivitylog files found at ${logsBuildDir}.`,
'',
'This typically happens when your project has a custom ios/Gymfile. To enable',
'build performance analysis, add the following lines to your Gymfile:',
'',
' derived_data_path("./build")',
' result_bundle(true)',
' result_bundle_path("./build/result-bundle.xcresult")',
].join('\n')
);
return { skipped: true };
}
phase = 'creating_temp_directory';
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
phase = 'downloading_xclogparser';
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
phase = 'resolving_xclogparser';
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
let binaryPath: string;
let resolvedVersion: string;
if (
preinstalledVersion &&
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
) {
binaryPath = 'xclogparser';
resolvedVersion = preinstalledVersion;
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
} else {
phase = 'downloading_xclogparser';
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
binaryPath = await downloadXclogparser({
tempDir,
version: resolvedVersion,
logger,
proxyBaseUrl,
env,
});
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
}
phase = 'running_xclogparser';
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
binaryPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
env,
});
phase = 'parsing_xclogparser_output';
@@ -59,10 +107,20 @@ export async function parseAndReportXcactivitylog({
);
logger.info(formatReport(data));
return { skipped: false };
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, { tags: { phase } });
Sentry.capture(msg, err, {
tags: { phase },
extras: {
exitStatus: err?.status,
signal: err?.signal,
stderr: err?.stderr?.slice(-4000),
stdout: err?.stdout?.slice(-4000),
},
});
return { skipped: true };
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -70,12 +128,28 @@ export async function parseAndReportXcactivitylog({
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
if (!result.ok) {
return null;
}
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
return match ? `v${match[1]}` : null;
}
async function downloadXclogparser({
tempDir,
version,
logger,
proxyBaseUrl,
env,
}: {
tempDir: string;
version: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
@@ -83,7 +157,7 @@ async function downloadXclogparser(
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
@@ -96,31 +170,35 @@ async function downloadXclogparser(
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
env,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
env: BuildStepEnv;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
return await unpackXclogparser({ tempDir, zipPath, env });
}
async function unpackXclogparser({
tempDir,
zipPath,
env,
}: {
tempDir: string;
zipPath: string;
env: BuildStepEnv;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
@@ -143,11 +221,13 @@ async function runXclogparser({
derivedDataPath,
workspacePath,
outputDir,
env,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
env: BuildStepEnv;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
@@ -161,10 +241,13 @@ async function runXclogparser({
workspacePath,
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
outputPath,
],
{ stdio: 'pipe' }
{ stdio: 'pipe', env }
);
return outputPath;
@@ -420,7 +503,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' '.repeat(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -1,91 +0,0 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
@@ -1,13 +1,20 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { BuildStepEnv, spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -28,13 +35,156 @@ export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
if (!deviceRunSessionId) {
throw new SystemError(
'DEVICE_RUN_SESSION_ID is not set. ' +
'This step must run as part of a device run session created by the API server, ' +
'This step must run as part of a device run session ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
return deviceRunSessionId;
}
export function getNgrokTunnelDomainOrThrow(env: BuildStepEnv): string {
const baseDomain = env.EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN;
if (!baseDomain) {
throw new SystemError(
'EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN is not set. ' +
'This step must run as part of a device run session ' +
'which injects EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN into the job environment.'
);
}
return baseDomain;
}
export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
const authtoken = env.NGROK_AUTHTOKEN;
if (!authtoken) {
throw new SystemError(
'NGROK_AUTHTOKEN is not set. ' +
'This step must run as part of a device run session ' +
'which injects NGROK_AUTHTOKEN into the job environment.'
);
}
return authtoken;
}
const TurnIceServersSchema = z.array(
z.object({
urls: z.array(z.string()),
username: z.string().optional(),
credential: z.string().optional(),
})
);
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
export async function selectXcodeDeveloperDirectoryAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (process.env.ENVIRONMENT === 'development') {
logger.info('Job running outside of EAS, not selecting Xcode developer directory.');
return;
}
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawnAsync('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], {
env,
logger,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
const turnServer = iceServers.find(server => server.username && server.credential);
const args: string[] = [];
if (stunUrls.length > 0) {
args.push('--stun-url', stunUrls.join(','));
}
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
args.push(
'--turn-url',
turnServer.urls.join(','),
'--turn-username',
turnServer.username,
'--turn-credential',
turnServer.credential
);
}
return args;
}
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
try {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
);
return [];
}
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
@@ -59,22 +209,6 @@ export async function uploadRemoteSessionConfigAsync({
}
}
export async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
export type DetachedProcessHandle = {
getOutput: () => string;
};
@@ -111,27 +245,38 @@ export function spawnDetached({
return { getOutput: () => output };
}
export async function startServeSimWithTunnelAsync({
env,
logger,
timeoutMs,
}: {
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}
): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const serveSim = spawnDetached({
command: 'npx',
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-protocol',
'quic',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--codec',
'webrtc',
...turnArgs,
],
env,
});
@@ -140,8 +285,8 @@ export async function startServeSimWithTunnelAsync({
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl(output, 'Tunnel');
const streamUrl = matchLabeledUrl(output, 'Stream');
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
}
@@ -152,8 +297,83 @@ export async function startServeSimWithTunnelAsync({
);
}
function matchLabeledUrl(content: string, label: string): string | null {
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
const match = labelPattern.exec(content);
function matchLabeledUrl({
output,
label,
baseDomain,
}: {
output: string;
label: string;
baseDomain: string;
}): string | null {
const labelPattern = new RegExp(
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
);
const match = labelPattern.exec(output);
return match ? match[1] : null;
}
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
baseDomain,
authtoken,
rewriteHostHeader,
logger,
}: {
port: number;
subdomainPrefix: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<string> {
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({
addr: port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
});
const url = listener.url();
if (!url) {
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
return url;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function waitForFileAsync<T>({
filePath,
timeoutMs,
description,
parse,
}: {
filePath: string;
timeoutMs: number;
description: string;
parse: (raw: string) => T;
}): Promise<T> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
return parse(raw);
} catch (err) {
lastError = err;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to be ready at ${filePath}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -5,6 +5,7 @@ export const FastfileResignTemplate = `lane :do_resign do
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
},
use_app_entitlements: true,
keychain_path: "<%- KEYCHAIN_PATH %>"
)
end
@@ -1,3 +1,4 @@
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -92,6 +93,67 @@ export namespace IosSimulatorUtils {
});
}
export async function enableAccessibilitySettingsAsync({
deviceIdentifier,
env,
}: {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
try {
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(
device =>
device.isAvailable &&
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
);
if (!device) {
throw new UserError(
'EAS_IOS_SIMULATOR_NOT_FOUND',
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
);
}
if (device.state !== 'Shutdown') {
throw new UserError(
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
);
}
const plistPath = path.join(
device.dataPath,
'Library',
'Preferences',
'com.apple.Accessibility.plist'
);
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
const plistExists = await fs.promises
.access(plistPath)
.then(() => true)
.catch(() => false);
if (!plistExists) {
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
}
for (const key of [
'AutomationEnabled',
'IgnoreAXServerEntitlements',
'AccessibilityEnabled',
'ApplicationAccessibilityEnabled',
]) {
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
}
} catch (err) {
if (err instanceof ExpoError) {
throw err;
}
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
cause: err,
});
}
}
export async function startAsync({
deviceIdentifier,
env,
@@ -122,9 +184,10 @@ export namespace IosSimulatorUtils {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
await retryAsync(
async () => {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', '/dev/null'], {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
env,
});
},
@@ -136,6 +199,7 @@ export namespace IosSimulatorUtils {
},
}
);
await fs.promises.rm(readinessScreenshotPath, { force: true });
// Wait for data migration to complete before declaring the simulator ready
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
@@ -155,6 +219,25 @@ export namespace IosSimulatorUtils {
);
}
export async function disableApsdAsync({
udid,
env,
}: {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env }
);
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env }
);
}
export async function collectLogsAsync({
deviceIdentifier,
env,
@@ -0,0 +1,199 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import os from 'node:os';
import path from 'node:path';
import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.startAsync, () => {
it('waits for boot completion without writing accessibility prefs', async () => {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
return {
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
stderr: '',
} as any;
}
return { stdout: '', stderr: '' } as any;
}) as any);
await IosSimulatorUtils.startAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).not.toHaveBeenCalledWith(
'xcrun',
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
expect.anything()
);
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
});
});
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
function device(overrides: Record<string, unknown> = {}) {
return {
dataPath: '/tmp/eas-test-simulator-data',
dataPathSize: 1,
logPath: '/tmp/eas-test-simulator-logs',
udid: 'test-udid',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
state: 'Shutdown',
name: 'iPhone 15',
...overrides,
};
}
function mockAvailableDevices(devices: Record<string, unknown>[]) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (
command === 'xcrun' &&
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
) {
return {
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
},
}),
stderr: '',
} as any;
}
return { stdout: '', stderr: '' } as any;
}) as any);
}
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
mockAvailableDevices([
device({
udid: 'test-udid-pro',
name: 'iPhone 17 Pro',
dataPath: '/tmp/eas-test-simulator-data-pro',
}),
device({ name: 'iPhone 17' }),
]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'iPhone 17' as any,
env: process.env,
})
).resolves.toBeUndefined();
for (const key of [
'AutomationEnabled',
'IgnoreAXServerEntitlements',
'AccessibilityEnabled',
'ApplicationAccessibilityEnabled',
]) {
expect(mockedSpawn).toHaveBeenCalledWith(
'plutil',
[
'-replace',
key,
'-bool',
'true',
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
],
{ env: process.env }
);
}
});
it('throws UserError when no available simulator exactly matches the identifier', async () => {
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'iPhone 17' as any,
env: process.env,
})
).rejects.toThrow(UserError);
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
});
it('throws UserError when the matched simulator is already booted', async () => {
mockAvailableDevices([device({ state: 'Booted' })]);
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
})
).rejects.toThrow(UserError);
});
it('throws SystemError when pre-boot accessibility setup fails', async () => {
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
await expect(
IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: 'test-udid' as any,
env: process.env,
})
).rejects.toThrow(SystemError);
});
});
describe(IosSimulatorUtils.waitForReadyAsync, () => {
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
await IosSimulatorUtils.waitForReadyAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'io',
'test-udid',
'screenshot',
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
],
{ env: process.env }
);
});
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
it('disables and boots out apsd in the simulator', async () => {
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env: process.env }
);
});
});
});
@@ -71,6 +71,26 @@ describe(findArtifacts, () => {
expect(paths.length).toBe(2);
});
test('with absolute glob pattern', async () => {
await fs.mkdirp('/tmp');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
const loggerMock = {
info: jest.fn(),
error: jest.fn(),
};
const paths = await findArtifacts({
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
logger: loggerMock as any,
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(paths).toEqual([
'/tmp/maestro_xctestrunner_xcodebuild_output123',
'/tmp/maestro_xctestrunner_xcodebuild_output456',
]);
});
test('with missing file in empty directory', async () => {
await fs.mkdirp('/dir1/dir2/dir3');
let errMsg = '';
@@ -104,4 +104,33 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => {
expect(iosSetChannelNativelyAsync).toBeCalledTimes(1);
expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1);
});
it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => {
jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0');
const warn = jest.fn();
const managedCtx: BuildContext<BuildJob> = {
appConfig: {},
env: {},
job: {
platform: Platform.IOS,
},
logger: { warn },
metadata: {
runtimeVersion: 'runtime-version-from-local-machine',
},
getReactNativeProjectDirectory: () => '/app',
} as any;
await expect(
expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, {
resolvedRuntimeVersion: 'runtime-version-from-eas-build',
resolvedFingerprintSources: [],
})
).rejects.toThrow(
'Runtime version calculated on local machine not equal to runtime version calculated during build.'
);
expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set');
});
});
@@ -0,0 +1,273 @@
import { Platform } from '@expo/eas-build-job';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
jest.mock('../expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
const mockZipEntries = jest.fn();
const mockZipExtract = jest.fn();
const mockZipClose = jest.fn();
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(() => ({
entries: mockZipEntries,
extract: mockZipExtract,
close: mockZipClose,
})),
},
}));
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
const job =
overrides.platform === Platform.IOS
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
mockZipExtract.mockResolvedValue(undefined);
mockZipClose.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('uploads from Android APK archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
'update:embedded:upload',
'--platform',
Platform.ANDROID,
'--channel',
'production',
'--build-id',
'build-123',
]),
})
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'base/assets/index.android.bundle': true,
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalled();
});
it('uploads from iOS IPA archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'Payload/App.app/main.jsbundle': true,
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
})
);
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns and continues when CLI upload throws', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
});
+6 -5
View File
@@ -19,11 +19,12 @@ export async function findArtifacts({
/** If provided, will log error suggesting possible files to upload. */
logger: bunyan | null;
}): Promise<string[]> {
const files = path.isAbsolute(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
const files =
path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
if (files.length === 0) {
if (fg.isDynamicPattern(patternOrPath)) {
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
@@ -243,6 +243,12 @@ async function logDiffFingerprints({
ctx: BuildContext<BuildJob>;
}): Promise<void> {
const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime;
const buildId = ctx.env.EAS_BUILD_ID;
if (!buildId) {
ctx.logger.warn('Skipping fingerprint diff because EAS_BUILD_ID is not set');
return;
}
const fingerprintInfo = await ctx.graphqlClient
.query(
@@ -257,7 +263,7 @@ async function logDiffFingerprints({
}
}
`),
{ id: ctx.env.EAS_BUILD_ID }
{ id: buildId }
)
.toPromise();
@@ -0,0 +1,118 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
}
const channel = ctx.job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
}
const [archivePath] = await findArtifacts({
rootDir: projectDir,
patternOrPath: archivePattern,
logger: null,
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
const bundlePath = path.join(tmpDir, bundleName);
const manifestPath = path.join(tmpDir, 'app.manifest');
const zip = new StreamZip.async({ file: archivePath });
try {
const entries = Object.values(await zip.entries());
const bundleEntry = entries.find(e =>
platform === Platform.IOS
? e.name.endsWith('/main.jsbundle')
: e.name.endsWith('assets/index.android.bundle')
);
const manifestEntry = entries.find(e =>
platform === Platform.IOS
? e.name.includes('EXUpdates.bundle/app.manifest')
: e.name.endsWith('assets/app.manifest')
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
}
await zip.extract(bundleEntry.name, bundlePath);
await zip.extract(manifestEntry.name, manifestPath);
const args = [
'update:embedded:upload',
'--platform',
platform,
'--bundle',
bundlePath,
'--manifest',
manifestPath,
'--channel',
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
} finally {
await asyncResult(zip.close());
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.13.1",
"version": "20.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.13.1"
"@expo/steps": "20.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.5.0",
"version": "20.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "18.5.0",
"version": "20.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.13.1",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.5.0",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/turtle-spawn": "20.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -1,4 +1,18 @@
import { StaticWorkflowInterpolationContextZ } from '../common';
import { EnvSchema, StaticWorkflowInterpolationContextZ } from '../common';
describe('EnvSchema', () => {
it('accepts explicit undefined values', () => {
const env = {
DEFINED_ENV: 'value',
UNDEFINED_ENV: undefined,
};
const { value, error } = EnvSchema.validate(env);
expect(error).toBeUndefined();
expect(value).toEqual(env);
});
});
describe('StaticWorkflowInterpolationContextZ', () => {
it('accepts app and account context', () => {
@@ -534,4 +534,47 @@ describe('Ios.JobSchema', () => {
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('accepts optional refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
refreshAdHocProvisioningProfile: true,
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBe(true);
expect(error).toBeFalsy();
});
test('does not require refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBeUndefined();
expect(error).toBeFalsy();
});
});
+2 -8
View File
@@ -115,8 +115,8 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
}),
]);
export type Env = Record<string, string>;
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string());
export type Env = Record<string, string | undefined>;
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string().optional());
export type EnvironmentSecret = {
name: string;
@@ -181,12 +181,6 @@ export const CacheSchema = Joi.object({
paths: Joi.array().items(Joi.string()).default([]),
});
export interface BuildPhaseStats {
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
const GitHubContextZ = z.object({
triggering_actor: z.string().optional(),
event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']),
+1 -1
View File
@@ -11,7 +11,7 @@ type StaticJobOnlyInterpolationContext = {
outputs: Record<string, string | undefined>;
}
>;
expoApiServerURL: string;
expoApiServerURL: string | undefined;
};
export type StaticJobInterpolationContext =
-1
View File
@@ -5,7 +5,6 @@ export {
ArchiveSource,
ArchiveSourceSchemaZ,
BuildMode,
BuildPhaseStats,
BuildTrigger,
EasCliNpmTags,
Env,
+4
View File
@@ -144,6 +144,8 @@ export interface Job {
appId: string;
environment?: string;
refreshAdHocProvisioningProfile?: boolean;
}
const SecretsSchema = Joi.object({
@@ -229,6 +231,8 @@ export const JobSchema = Joi.object({
environment: Joi.string(),
refreshAdHocProvisioningProfile: Joi.boolean(),
workflowInterpolationContext: Joi.object().custom(workflowInterpolationContext =>
StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext)
),
+3
View File
@@ -27,6 +27,7 @@ export enum BuildPhase {
*/
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
@@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'Cache stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
@@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'cache-stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
+516 -116
View File
File diff suppressed because it is too large. Load diff
+7761 -27
View File
File diff suppressed because it is too large. Load diff
+11 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.13.1",
"version": "20.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -27,7 +27,7 @@
],
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"prepack": "yarn sync-agent-templates && yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
@@ -36,6 +36,7 @@
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"sync-agent-templates": "node scripts/sync-agent-templates.js",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"verify-graphql-code": "./scripts/verify-graphql.sh",
@@ -43,15 +44,15 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.19",
"@expo/apple-utils": "2.1.22",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.13.1",
"@expo/eas-json": "18.13.1",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.0",
"@expo/logger": "20.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.13.1",
"@expo/steps": "20.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -224,6 +225,9 @@
"update": {
"description": "manage individual updates"
},
"update:embedded": {
"description": "manage embedded updates registered with EAS Update"
},
"webhook": {
"description": "manage webhooks"
},
@@ -0,0 +1,31 @@
const fs = require('fs/promises');
const path = require('path');
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
async function fetchTemplateAsync(fileName) {
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return await response.text();
}
(async () => {
const templatesDir = path.join(__dirname, '..', 'src', 'commandUtils', 'new', 'templates');
await Promise.all(
AGENT_TEMPLATE_FILE_NAMES.map(async fileName => {
const content = await fetchTemplateAsync(fileName);
await fs.writeFile(path.join(templatesDir, fileName), content);
})
);
// eslint-disable-next-line no-console
console.log('Synced new app agent templates from expo/llm-configs');
})();
@@ -0,0 +1,197 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
getConfigFilePaths: jest.fn(),
}));
jest.mock('../../project/expoConfig');
jest.mock('../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
withTick: jest.fn(),
newLine: jest.fn(),
succeed: jest.fn(),
debug: jest.fn(),
markFreshLine: jest.fn(),
error: jest.fn(),
},
learnMore: jest.fn().mockReturnValue(''),
}));
jest.mock('../../ora', () => ({
ora: jest.fn().mockReturnValue({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
}),
}));
jest.mock('fs-extra', () => ({
ensureDir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../build/utils/url');
jest.mock('../../prompts');
const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
describe('detectProjectSdkVersionAsync', () => {
it('returns undefined when no config file exists', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
it('returns the sdkVersion from the project config', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
});
it('returns undefined when reading the config throws', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
});
const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
id: 'user-id',
username: 'testuser',
primaryAccount: mockAccount,
};
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
} as any);
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
id: 'run-id',
} as any);
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
projectArchiveBucketKey: 'archive-key',
});
jest
.mocked(uploadAccountScopedFileAsync)
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
});
afterEach(() => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
};
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
return cmd;
}
it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd().run();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
});
it('skips auto-select log when --sdk-version flag is provided', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd(['--sdk-version', '55.0.0']).run();
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
it('prompts for SDK version when no project config is found', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: false },
{ sdkVersion: '55.0.0', isLatest: true, isBeta: false, isDeprecated: false },
{ sdkVersion: '56.0.0', isLatest: false, isBeta: true, isDeprecated: false },
]);
jest.mocked(selectAsync).mockResolvedValue('55.0.0');
await makeCmd().run();
expect(selectAsync).toHaveBeenCalledWith(
'Select an Expo SDK version',
expect.arrayContaining([
expect.objectContaining({ title: 'SDK 54', value: '54.0.0' }),
expect.objectContaining({ title: 'SDK 55 (latest)', value: '55.0.0' }),
expect.objectContaining({ title: 'SDK 56 (beta)', value: '56.0.0' }),
]),
expect.objectContaining({ initial: '55.0.0' })
);
});
it('skips prompt when all versions are deprecated', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: true },
]);
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
it('falls back gracefully when supportedSdkVersions fetch fails', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockRejectedValue(new Error('network error'));
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
});
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more