Compare commits

..
Author SHA1 Message Date
Krystof Woldrich eb3caef49b feat(simulator): expose topic commands 2026-05-20 16:24:09 +02:00
Stanisław Chmiela 6ec2ea0530 [eas-cli] Capture iOS config introspection fallback in Sentry (#3759)
## Summary
- capture local Expo CLI iOS entitlements introspection failures in Sentry before falling back to bundled config resolution
- tag the event with `app_id` from `EAS_BUILD_PROJECT_ID` and the config resolution path
- capture a short fallback message so this is easier to distinguish from generic spawn failures
- include the failed command stdout, stderr, and exit status as Sentry extras

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

## Validation

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

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

# How

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

# Test Plan

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

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

* address comment

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

---------

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

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

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

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

## Validation

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

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

## Validation

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

* gradleProfileTasks

* individual tasks nested underneath with tree characters

* filter out whats less than 1s

* parseGradleProfile

* profile flag

* enable by default

* gradleProfile utils

* parse profile HTML with fast-xml-parser

* parse profile HTML with fast-xml-parser

* error handling, send to sentry

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

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

# How

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

Example:

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

Med values (navigation count) for the last 60 days

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

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

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

Med values (navigation count) for the last 60 days

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

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

# Test Plan

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

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

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

Ref ENG-21208

# How

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

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

# Test Plan

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

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

# How

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

Fixes #3630 .

# Test Plan

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

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

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

* Remove npx invocation in "new" command

* Remove catch-case from `getExpoConfigInternalAsync`

* Remove catch-case from `getManagedApplicationTargetEntitlementsAsync`

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

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

* Update commands.test.ts

* Add CHANGELOG entry

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

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

# Why

Use parameters to improve tunnel perf.

# How

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

# Test Plan

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

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

# How

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

# Test Plan

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

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

# How

Refactored the observe commands to extract flag definitions.

# Test Plan

Commands tested locally.

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

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

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

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

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

```

# How

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

# Test Plan

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

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

* [worker] - remove logging errors to datadog

* rem tests

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

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

# How

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

# Test Plan

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

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

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

# How

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

# Test Plan

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

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

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

* address comments

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

---------

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

.

* eas go: remove unused uploadAccountScopedBufferToGCSAsync and its tests

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

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

* fix fmt

* eas go: restore spinner failure messages

* eas go: address review comments

* eas go: clean up minor issues

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

# Why

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

# How

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

# Test Plan

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

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

# Why

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

# How

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

# Test Plan

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

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

# How

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

# Test Plan

Ran against a running session:

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

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

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

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

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

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

The `DeviceRunSessionByIdQuery` now selects:

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

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

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

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

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

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

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

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

# Why

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

# How

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

# Test Plan

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

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

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

## Changes

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

## Validation

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

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

Fixes ENG-21068

# How

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

# Test Plan

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

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

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

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

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

# Why

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

# How

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

# Test Plan

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

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

* fix param name & use zod according to previous comments

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

* address comments

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

---------

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

* route

* update routes

* remove key tags

* remove key tags

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

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

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

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

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

* address comments

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

---------

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

* add changelog

* merge branch main

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

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

# Why

Add new command to retrieve custom events for Expo Observe.

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

ARGUMENTS
  [EVENTNAME]  Custom event name to filter by

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

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


```

# How

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

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

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

# Test Plan

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

# Why

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

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

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

# How

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

# Test Plan

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

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

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

yarn build

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# How

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

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

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

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

# Test Plan

```bash
cd packages/eas-cli

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-30 11:46:25 -07:00
Stanisław Chmiela 7fde621858 [build-tools] Fix worker integration test mocks (#3662)
## Summary
- Mock `@expo/turtle-spawn` to surface the missing `expo/package.json` error path in the worker integration test
- Mock `turtleFetch` so the test can proceed without external fetch behavior
2026-04-30 18:39:45 +02:00
190 changed files with 15531 additions and 3343 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
# upstream security patches because tag movements no longer reach us.
- package-ecosystem: github-actions
directories:
- /
- /.github/actions/setup-mise
- /.github/internal-actions/setup-gcloud
- /.github/internal-actions/notify-slack-on-fail-or-recover
schedule:
interval: weekly
groups:
actions:
patterns:
- '*'
@@ -12,7 +12,7 @@ runs:
using: 'composite'
steps:
- name: Get previous workflow run status
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
id: run-status
with:
script: |
@@ -28,7 +28,7 @@ runs:
- name: Send Slack Success Notification
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: good
@@ -41,7 +41,7 @@ runs:
- name: Send Slack Failure Notification
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: danger
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@v2
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
@@ -26,11 +26,11 @@ jobs:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/[email protected]
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: always()
with:
message-id: changelog-entry-check
+7 -1
View File
@@ -1,5 +1,8 @@
name: codemention
on:
# pull_request_target is required so fork PRs get a write token to post the
# mention comment. Safe because there is no checkout — the action only reads
# PR metadata via the GitHub API; no fork code is fetched or executed.
pull_request_target:
types: [opened, synchronize, ready_for_review]
jobs:
@@ -9,6 +12,9 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: tobyhs/[email protected]
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag from
# a compromised upstream account would give the attacker repo write access.
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -81,7 +81,7 @@ jobs:
runs-on: ubuntu-20.04
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -126,7 +126,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@v2
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@v1
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -29,7 +29,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --immutable
@@ -58,7 +58,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
@@ -91,7 +91,7 @@ jobs:
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v4
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
with:
ascending: false
operations-per-run: 300
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
@@ -66,7 +66,7 @@ jobs:
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@v1
- uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192 # v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -79,7 +79,7 @@ jobs:
name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
+7
View File
@@ -1,2 +1,9 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
+93 -1
View File
@@ -10,10 +10,102 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
### 🧹 Chores
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
### 🐛 Bug fixes
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
### 🎉 New features
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
### 🎉 New features
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.9.1",
"version": "19.0.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.9.0",
"version": "19.0.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.8.0",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/logger": "19.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.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/steps": "19.0.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
@@ -1,10 +1,7 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
module.exports = { ...fs.promises, rm: fsRm };
@@ -1,14 +1,36 @@
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -116,4 +138,68 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -0,0 +1,150 @@
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', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows upload failures', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
});
Datadog.distribution('eas.build.phase_duration', 1);
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'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/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
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);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -0,0 +1,256 @@
import * as sentryNode from '@sentry/node';
import { Sentry } from '../sentry';
jest.mock('@sentry/node');
describe('Sentry singleton', () => {
beforeEach(() => {
jest.mocked(sentryNode.init).mockReset();
jest.mocked(sentryNode.flush).mockReset();
jest.mocked(sentryNode.captureException).mockReset();
jest.mocked(sentryNode.captureMessage).mockReset();
jest.mocked(sentryNode.withScope).mockReset();
});
describe('setup', () => {
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
});
});
it('passes default tags into the SDK init as initialScope', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'production',
tags: { service: 'worker:darwin' },
});
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
initialScope: { tags: { service: 'worker:darwin' } },
});
});
it('skips SDK init when dsn is null', () => {
Sentry.setup({ dsn: null, environment: 'development' });
expect(sentryNode.init).not.toHaveBeenCalled();
});
it('passes the latest initialScope tags when setup is called more than once', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'first' },
});
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'second' },
});
expect(sentryNode.init).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
);
});
});
describe('capture', () => {
it('routes (msg) to captureMessage with msg only', () => {
const scope = mockWithScope();
Sentry.capture('plain message');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
expect(sentryNode.captureException).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
});
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
const scope = mockWithScope();
Sentry.capture('something happened', {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
level: 'warning',
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture('something went wrong', err);
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
const scope = mockWithScope();
const err = new Error('spawn ENOENT');
Sentry.capture('xclogparser failed', err, {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('does not set the `message` extra when msg equals err.message', () => {
const scope = mockWithScope();
const err = new Error('build failed');
Sentry.capture('build failed', err);
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('routes (err) alone to captureException with no scope mutations', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err);
expect(scope.setTags).not.toHaveBeenCalled();
expect(scope.setExtras).not.toHaveBeenCalled();
expect(scope.setLevel).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null', null as unknown as Error);
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, null, options) as a no-err call', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null with opts', null as unknown as Error, {
tags: { phase: 'cleanup' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded with no err', undefined, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
mockWithScope();
Sentry.capture('caught a string', 'boom' as unknown as Error);
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('boom');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
const scope = mockWithScope();
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('raw string thrown');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (err, options) to captureException with no message extra', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
});
describe('flush', () => {
it('forwards the timeout to the SDK and resolves with its result', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
const result = await Sentry.flush(3000);
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
expect(result).toBe(true);
});
it('defaults the timeout to 2000ms when not specified', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
await Sentry.flush();
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
});
});
});
function mockWithScope(): {
setTags: jest.Mock;
setExtras: jest.Mock;
setExtra: jest.Mock;
setLevel: jest.Mock;
} {
const scope = {
setTags: jest.fn(),
setExtras: jest.fn(),
setExtra: jest.fn(),
setLevel: jest.fn(),
};
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
cb(scope as unknown as sentryNode.Scope);
return undefined as any;
});
return scope;
}
+21 -12
View File
@@ -32,18 +32,27 @@ export async function runGradleCommand(
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -0,0 +1,231 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
+14 -5
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';
@@ -173,17 +174,25 @@ 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 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);
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
});
}
} 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);
@@ -42,38 +42,42 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
try {
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
} catch (err: any) {
throw new Error('Failed to run eas build:internal');
}
}
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
+8
View File
@@ -20,6 +20,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';
@@ -355,6 +356,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(
+54
View File
@@ -0,0 +1,54 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingMetricUploads: Promise<unknown>[] = [];
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
},
];
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
extras: { metrics },
});
});
pendingMetricUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingMetricUploads);
},
};
+6
View File
@@ -20,4 +20,10 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
@@ -1,5 +1,6 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -8,6 +9,10 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
@@ -19,9 +24,11 @@ const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
}): BuildContext<Ios.Job> {
vol.fromJSON({
@@ -48,7 +55,10 @@ function makeIosBuildContext({
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
});
}
@@ -56,32 +66,23 @@ function makeIosBuildContext({
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
@@ -89,14 +90,38 @@ describe(installPods, () => {
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
});
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
EXPO_PRECOMPILED_MODULES_BASE_URL:
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
}),
})
);
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
);
});
@@ -105,9 +130,11 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
)
);
});
@@ -116,7 +143,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
@@ -127,7 +154,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
@@ -136,7 +163,7 @@ describe(installPods, () => {
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '55.0.18',
expoVersion: '999.0.0',
usePrecompiledModules: false,
});
@@ -144,6 +171,9 @@ describe(installPods, () => {
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
});
+19 -29
View File
@@ -1,13 +1,13 @@
import { Env, Ios } from '@expo/eas-build-job';
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
@@ -52,7 +52,10 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
} catch (err) {
ctx.logger.info(
{ err },
@@ -76,10 +79,9 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
};
ctx.logger.info(
@@ -93,26 +95,14 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
return PRECOMPILED_MODULES_BASE_URL;
}
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
// return PRECOMPILED_MODULES_BASE_URL;
// }
//
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
// return PRECOMPILED_MODULES_BASE_URL.replace(
// `${parsedUrl.protocol}//${parsedUrl.host}`,
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
// );
// }
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
+81
View File
@@ -0,0 +1,81 @@
import * as sentryNode from '@sentry/node';
type CaptureOptions = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
level?: sentryNode.SeverityLevel;
};
type SentryAPI = {
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
capture(msg: string, options?: CaptureOptions): void;
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
capture(err: Error, options?: CaptureOptions): void;
flush(timeoutMs?: number): Promise<boolean>;
};
export const Sentry: SentryAPI = {
setup({ dsn, environment, tags }) {
if (dsn) {
sentryNode.init({
dsn,
environment,
...(tags ? { initialScope: { tags } } : {}),
});
}
},
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
let msg: string | undefined;
let err: Error | undefined;
let options: CaptureOptions = {};
if (arg1 instanceof Error) {
err = arg1;
options = (arg2 as CaptureOptions | undefined) ?? {};
} else {
msg = arg1;
if (arg3 !== undefined) {
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
options = arg3;
if (arg2 !== undefined && arg2 !== null) {
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
}
} else if (arg2 instanceof Error) {
err = arg2;
} else if (arg2 !== undefined && arg2 !== null) {
// 2-arg form: non-Error object → options; primitive → coerced err
if (typeof arg2 === 'object') {
options = arg2;
} else {
err = new Error(String(arg2));
}
}
}
sentryNode.withScope(scope => {
if (options.tags) {
scope.setTags(options.tags);
}
if (options.extras) {
scope.setExtras(options.extras);
}
if (options.level) {
scope.setLevel(options.level);
}
if (err) {
if (msg && err.message !== msg) {
scope.setExtra('message', msg);
}
sentryNode.captureException(err);
} else if (msg) {
sentryNode.captureMessage(msg);
}
});
},
flush(timeoutMs: number = 2000): Promise<boolean> {
return sentryNode.flush(timeoutMs);
},
};
@@ -43,6 +43,7 @@ import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/st
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -79,10 +80,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -0,0 +1,302 @@
import { vol } from 'memfs';
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
import { createMockLogger } from '../../../__tests__/utils/logger';
describe(readFlowName, () => {
beforeEach(() => vol.reset());
it('returns top-level `name` when present', async () => {
vol.fromJSON({
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
});
it('falls back to basename without extension when name absent', async () => {
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is empty string', async () => {
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is non-string', async () => {
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when YAML is malformed', async () => {
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file is empty', async () => {
vol.fromJSON({ '/p/login.yml': '' });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file does not exist', async () => {
expect(await readFlowName('/p/missing.yml')).toBe('missing');
});
it('only parses the first YAML document (commands document errors are ignored)', async () => {
vol.fromJSON({
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
it('handles .yaml extension', async () => {
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
});
it('handles a leading `---` document marker', async () => {
vol.fromJSON({
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
});
describe(walkDir, () => {
beforeEach(() => vol.reset());
it('collects all .yml/.yaml files recursively, sorted', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/a.yml': '',
'/p/flows/b.yaml': '',
'/p/flows/sub/c.yml': '',
'/p/flows/sub/d.YAML': '',
'/p/flows/readme.md': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual([
'/p/flows/a.yml',
'/p/flows/b.yaml',
'/p/flows/sub/c.yml',
'/p/flows/sub/d.YAML',
]);
});
it('skips files named config.yaml (case-insensitive)', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/config.yaml': '',
'/p/flows/CONFIG.yaml': '',
'/p/flows/login.yml': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual(['/p/flows/login.yml']);
});
it('returns silently when realpath fails (e.g. missing dir)', async () => {
const logger = createMockLogger();
const out: string[] = [];
await walkDir('/p/missing', new Set(), out, logger);
expect(out).toEqual([]);
});
});
describe(buildFlowNameToPathMap, () => {
beforeEach(() => vol.reset());
it('builds a map from a single file input (basename name)', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
});
it('uses top-level name when present', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
});
it('walks a directory input and adds each flow', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/login.yml': 'name: Login\n',
'/proj/.maestro/checkout.yml': '',
'/proj/.maestro/sub/profile.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['Login', '.maestro/login.yml'],
['checkout', '.maestro/checkout.yml'],
['profile', '.maestro/sub/profile.yml'],
])
);
});
it('merges file + directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/a.yml': '',
'/proj/extra/b.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', 'extra/b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['a', '.maestro/a.yml'],
['b', 'extra/b.yml'],
])
);
});
it('skips non-existent inputs with warn', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
});
it('skips workspace config.yaml inside directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
'/proj/.maestro/login.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
});
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro/config.yaml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
});
it('returns null when two files share top-level name', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/a.yml': 'name: Login\n',
'/proj/b.yml': 'name: Login\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['a.yml', 'b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
});
it('returns null when two files share basename across directories', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login/index.yml': '',
'/proj/checkout/index.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login', 'checkout'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('returns null when name collides with another file basename', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login.yml': 'name: Other\n',
'/proj/Other.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login.yml', 'Other.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('dedupes the same physical file passed via overlapping inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
expect(logger.warn).not.toHaveBeenCalled();
});
it('uses absolute fallback when input file lives outside projectRoot', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/elsewhere/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/elsewhere/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
});
it('absolute directory input under projectRoot yields project-relative children', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/proj/.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
});
it('returns null and warns on unexpected helper failure', async () => {
const logger = createMockLogger();
const map = await buildFlowNameToPathMap({
inputFlowPaths: [Symbol('bad') as unknown as string],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
);
});
});
@@ -1,65 +1,18 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
parseFlowMetadata,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
it('parses JUnit results and enriches with name→path map', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -71,18 +24,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
@@ -110,7 +60,7 @@ describe(parseMaestroResults, () => {
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
it('uses flow name as fallback path when nameToPath is null', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -120,49 +70,33 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', null);
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
it('uses flow name as fallback path when name not in map', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map());
expect(results[0].path).toBe('home');
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results).toEqual([]);
});
@@ -181,44 +115,13 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Attempt 0: login FAILED
@@ -241,20 +144,11 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
// Should return 2 results — one per attempt
// Should return 2 results — one per attempt; retryCount from filename
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
@@ -300,25 +194,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
@@ -332,7 +216,6 @@ describe(parseMaestroResults, () => {
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -346,7 +229,6 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: all passed across shards
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -358,25 +240,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(4);
expect(results).toEqual([
@@ -387,52 +259,8 @@ describe(parseMaestroResults, () => {
]);
});
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
vol.fromJSON({
// Single overwritten JUnit (only has final attempt's results)
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// 2 timestamp dirs — both flows appear in both (entire suite retried)
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
// Both flows have 2 occurrences → retryCount = 1 for both
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
])
);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -451,21 +279,14 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
@@ -475,51 +296,6 @@ describe(parseMaestroResults, () => {
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
@@ -806,6 +582,294 @@ describe(parseJUnitTestCases, () => {
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites>
<testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Search" status="SUCCESS" time="1.0" />
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
</testsuite>
</testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([
['Login', 'flows/login.yaml'],
['Search', 'flows/search.yaml'],
['Checkout', 'flows/checkout.yaml'],
]),
});
expect(result).toEqual(['flows/checkout.yaml']);
});
it('returns null when a failing testcase has no entry in nameToPath', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['Login', 'flows/login.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when two failing testcases share the same name (collision)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Shared" status="SUCCESS" time="1.0" />
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file does not exist', async () => {
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/missing.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file is malformed', async () => {
vol.fromJSON({
'/tmp/junit-reports/bad.xml': 'this is not xml',
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/bad.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
// fast-xml-parser can produce a partial parse from truncated XML — without
// strict validation, retry-failed-only would only retry the visible failures and
// silently skip flows that were cut off.
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
<testcase name="B"`, // intentionally truncated mid-tag
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([
['A', 'flows/a.yaml'],
['B', 'flows/b.yaml'],
]),
});
expect(result).toBeNull();
});
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
</testsuite>`, // missing </testsuites>
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
});
describe('mergeJUnitReports', () => {
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
// Single-attempt runs should land in final_report_path with the same
// suite-level attributes (tests/failures/time) and non-testcase children
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
// rebuild path used for multi-attempt merges drops these, so the single
// case must short-circuit to a byte-equivalent copy.
const input = `<?xml version="1.0"?>
<testsuites>
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" status="SUCCESS" time="2.0" />
<system-out>boot complete</system-out>
</testsuite>
</testsuites>`;
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
});
await mergeJUnitReports({
sourceDir: '/tmp/junit-reports',
outputPath: '/tmp/final.xml',
});
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
expect(out).toBe(input);
});
it('keeps the latest attempt per flow name across multiple files', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="B" status="SUCCESS" time="3.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const a = testcases.find((t: any) => t['@_name'] === 'A');
const b = testcases.find((t: any) => t['@_name'] === 'B');
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
expect(b.failure).toBeUndefined();
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
// — without a throw here, mergeJUnitReports would silently write an empty
// <testsuite> and the caller would upload a misleading empty report.
vol.fromJSON({
'/tmp/r/.gitkeep': '',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no \*\.xml files/);
});
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
// Without a throw, mergeJUnitReports would silently emit an empty merged
// document and the copy-latest fallback (which only triggers on throw)
// would never run.
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/invalid XML/);
});
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite></testsuite></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no parseable testcases/);
});
it('throws when any input XML is malformed (even if others parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': 'garbage not xml',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
// fast-xml-parser is lenient and can return partial results from truncated
// XML. Without strict validation, mergeJUnitReports would emit a merged
// report missing the cut-off flows and Phase 3's copy-latest fallback
// would never run. Validation must reject the file.
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
<testcase name="B"`, // truncated mid-tag
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
</testsuite>`, // missing </testsuites>
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('preserves duplicate names within the same attempt', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Dup" status="SUCCESS" time="1.0" />
<testcase name="Dup" status="SUCCESS" time="2.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
out
);
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
@@ -3,6 +3,7 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
@@ -49,10 +50,10 @@ function createStep(
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub copyLatestAttemptXml so tests that don't care about it
// don't hit the real disk. Individual tests override this.
// Default-stub the merge helper so tests that don't care about it don't
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
@@ -141,10 +142,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries all flows on failure', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
it('retries only the failed flows on the next attempt (junit mode)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
@@ -154,6 +154,32 @@ describe('createMaestroTestsBuildFunction', () => {
});
await step.executeAsync();
// Attempt 0 saw all 3
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
// Attempt 1 saw only the failed one
const a1Args = mockedSpawn.mock.calls[1][1]!;
expect(a1Args).toContain('flows/b.yaml');
expect(a1Args).not.toContain('flows/a.yaml');
expect(a1Args).not.toContain('flows/c.yaml');
});
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Both attempts saw all 3 flows
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
@@ -162,8 +188,150 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('writes final report via copyLatestAttemptXml on success', async () => {
it('always retries all flows when output_format is not junit', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('runs all original flows on every retry when retry_failed_only=false', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: false,
platform: 'android',
});
await step.executeAsync();
// Both attempts must see both flows in the original input order. Tail-slice
// equality locks order, not just membership — guards against any future
// failed-first reshuffling of the dumb-retry list.
const a0 = mockedSpawn.mock.calls[0][1]!;
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
expect(parseSpy).not.toHaveBeenCalled();
});
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('writes merged junit on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
@@ -173,19 +341,15 @@ describe('createMaestroTestsBuildFunction', () => {
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
expect(copySpy).toHaveBeenCalled();
});
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
@@ -230,6 +394,8 @@ describe('createMaestroTestsBuildFunction', () => {
pid: 1,
})
);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
@@ -298,9 +464,9 @@ describe('createMaestroTestsBuildFunction', () => {
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
@@ -309,13 +475,16 @@ describe('createMaestroTestsBuildFunction', () => {
});
await step.executeAsync();
// Phase 1: final_report_path populated
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
// Retry loop: --output written into junit-reports/ (not legacy path)
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
expect(copySpy).toHaveBeenCalledWith(
// Phase 3: merge invoked
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/root/project/.maestro/home.yml': '',
});
mockMutationFn.mockResolvedValue({
@@ -83,7 +83,11 @@ describe(createReportMaestroTestResultsFunction, () => {
},
});
await createStep().executeAsync();
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
@@ -0,0 +1,190 @@
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import path from 'path';
import yaml from 'yaml';
const YAML_EXT = /\.ya?ml$/i;
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
export interface BuildFlowNameToPathMapArgs {
inputFlowPaths: string[];
projectRoot: string;
logger: bunyan;
}
interface FlowEntry {
name: string;
path: string;
realpath: string;
}
export async function buildFlowNameToPathMap(
args: BuildFlowNameToPathMapArgs
): Promise<Map<string, string> | null> {
try {
const flows = await discoverFlows(args);
return dedupAndDetectDuplicates(flows, args.logger);
} catch (err: any) {
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
return null;
}
}
async function discoverFlows({
inputFlowPaths,
projectRoot,
logger,
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
const realRootResult = await asyncResult(fs.realpath(projectRoot));
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
const fileLists = await Promise.all(
inputFlowPaths.map(async input => {
const abs = path.resolve(projectRoot, input);
const statResult = await asyncResult(fs.stat(abs));
if (!statResult.ok) {
logger.warn(`flow_path entry "${input}" not found, skipping`);
return [];
}
const stat = statResult.value;
if (stat.isFile() && YAML_EXT.test(abs)) {
return [abs];
}
if (stat.isDirectory()) {
const out: string[] = [];
await walkDir(abs, new Set(), out, logger);
return out;
}
return [];
})
);
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
}
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
const [name, realFileResult] = await Promise.all([
readFlowName(absFile),
asyncResult(fs.realpath(absFile)),
]);
const realFile = realFileResult.ok ? realFileResult.value : absFile;
const rel = path.relative(realRoot, realFile);
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
return { name, path: value, realpath: realFile };
}
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
const byKey = new Map<string, FlowEntry>();
for (const f of flows) {
if (!byKey.has(f.realpath)) {
byKey.set(f.realpath, f);
}
}
const unique = [...byKey.values()];
const nameToPath = new Map<string, string>();
const duplicates = new Map<string, string[]>();
for (const f of unique) {
if (nameToPath.has(f.name)) {
const existing = nameToPath.get(f.name)!;
const list = duplicates.get(f.name) ?? [existing];
list.push(f.path);
duplicates.set(f.name, list);
}
nameToPath.set(f.name, f.path);
}
if (duplicates.size > 0) {
for (const [name, paths] of duplicates) {
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
}
logger.warn(
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
);
return null;
}
return nameToPath;
}
export async function walkDir(
dir: string,
visited: Set<string>,
out: string[],
logger: bunyan
): Promise<void> {
// Cycle protection: dedup on realpath (fall back to the original path when
// realpath fails so we still make progress on missing or restricted dirs).
const realResult = await asyncResult(fs.realpath(dir));
const real = realResult.ok ? realResult.value : dir;
if (visited.has(real)) {
return;
}
visited.add(real);
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: any) {
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
const tgtResult = await asyncResult(fs.stat(full));
if (!tgtResult.ok) {
continue;
}
const tgt = tgtResult.value;
if (tgt.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
} else if (entry.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (entry.isFile()) {
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
}
}
}
export async function readFlowName(absFile: string): Promise<string> {
const ext = path.extname(absFile);
const fallback = path.basename(absFile, ext);
let content: string;
try {
content = await fs.readFile(absFile, 'utf-8');
} catch {
return fallback;
}
let firstDoc;
try {
const docs = yaml.parseAllDocuments(content);
firstDoc = docs[0];
} catch {
return fallback;
}
if (!firstDoc || firstDoc.errors.length > 0) {
return fallback;
}
const parsed = firstDoc.toJS();
const name = parsed?.name;
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallback;
}
function isYamlFile(name: string): boolean {
return YAML_EXT.test(name);
}
function isWorkspaceConfig(name: string): boolean {
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
}
@@ -1,7 +1,6 @@
import { XMLParser } from 'fast-xml-parser';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
@@ -14,17 +13,9 @@ export interface MaestroFlowResult {
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
@@ -41,11 +32,9 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// Internal helper — not exported. Parses a single JUnit XML file.
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
@@ -109,11 +98,20 @@ async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]>
}
}
} catch {
// Skip malformed XML files
// Malformed XML — return whatever we collected before the parser bailed.
}
return results;
}
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return parseJUnitContent(content);
} catch {
return [];
}
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -127,49 +125,16 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
const perFile = await Promise.all(
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
);
return perFile.flat();
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
nameToPath: Map<string, string> | null
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
@@ -198,51 +163,8 @@ export async function parseMaestroResults(
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
@@ -251,83 +173,229 @@ export async function parseMaestroResults(
}
for (const [flowName, flowEntries] of resultsByName) {
const flowFilePath = flowPathMap.get(flowName);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: flowName;
if (flowEntries.length === 1) {
// Single result for this flow — use ai-*.json occurrence count for retryCount
// (backward compat with old-style single JUnit file that gets overwritten)
const { result } = flowEntries[0];
const occurrences = flowOccurrences.get(flowName) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
const flowPath = nameToPath?.get(flowName) ?? flowName;
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
} else {
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
}
return results;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
/**
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
* then falls back to dumb retry — re-run everything).
*
* Caller passes a precomputed `nameToPath` map built from the user's
* `inputFlowPaths`. We translate failed testcase names to flow paths via
* that map; unknown names → null.
*/
export async function parseFailedFlowsFromJUnit(args: {
junitFile: string;
nameToPath: Map<string, string>;
}): Promise<string[] | null> {
// fast-xml-parser is lenient — truncated XML can produce a partial parse
// with some testcases dropped. Trusting that subset for retry-failed-only would
// skip retries for cut-off flows; reject any malformed XML and fall back
// to dumb retry.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
const testcases = parseJUnitContent(content);
if (testcases.length === 0) {
return null;
}
return relative;
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Two testcases with the same name (pass+fail or fail+fail) make it
// impossible to map back to a single input flow_path. Signal "unknown".
// Complementary to the map-build duplicate check (which catches the case
// where tag filter hides duplication from JUnit).
const allNameCounts = new Map<string, number>();
for (const tc of testcases) {
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
}
for (const count of allNameCounts.values()) {
if (count > 1) {
return null;
}
}
const matched: string[] = [];
for (const tc of failing) {
const p = args.nameToPath.get(tc.name);
if (p === undefined) {
return null;
}
matched.push(p);
}
return matched;
}
/**
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
* attempt's <testcase> per unique flow name (latest determined from the
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
* and writes a merged document to outputPath.
*
* Throws on empty/malformed/no-testcase input so the caller can fall back to
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
* failure rows around and produce a misleading merged report.
*/
export async function mergeJUnitReports(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
}
interface FileGroup {
attemptIndex: number;
filename: string;
content: string;
testcasesByName: Map<string, unknown[]>;
}
const contents = await Promise.all(
xmlFiles.map(async f => ({
filename: f,
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
}))
);
const fileGroups: FileGroup[] = [];
for (const { filename, content } of contents) {
if (XMLValidator.validate(content) !== true) {
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
}
let parsed: any;
try {
parsed = xmlParser.parse(content);
} catch (err) {
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
}
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
if (!Array.isArray(cases)) {
continue;
}
for (const tc of cases) {
const name = tc?.['@_name'];
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
group.push(tc);
testcasesByName.set(name, group);
}
}
if (testcasesByName.size === 0) {
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
}
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
}
// Single attempt: copy the original XML so suite-level metadata (testsuite
// attributes, <system-out>, etc.) survives. The rebuild path below would
// collapse those to a single attribute-less <testsuite>.
if (fileGroups.length === 1) {
await fs.writeFile(args.outputPath, fileGroups[0].content);
return;
}
// For each unique name, pick the file with the highest attempt index that
// contains it (ties broken by sorted filename — later wins). Preserve every
// <testcase> element from the winning file for that name, so same-attempt
// duplicates survive.
const nameToWinningFile = new Map<string, FileGroup>();
for (const group of fileGroups) {
for (const name of group.testcasesByName.keys()) {
const current = nameToWinningFile.get(name);
if (!current || group.attemptIndex >= current.attemptIndex) {
nameToWinningFile.set(name, group);
}
}
}
// Emit in first-seen order (iteration over `fileGroups` yields stable order
// matching the sorted filename list).
const testcases: unknown[] = [];
const emitted = new Set<string>();
for (const group of fileGroups) {
for (const [name, cases] of group.testcasesByName) {
if (emitted.has(name)) {
continue;
}
const winner = nameToWinningFile.get(name);
if (winner === group) {
for (const tc of cases) {
testcases.push(tc);
}
emitted.add(name);
}
}
}
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
suppressEmptyNode: true,
});
const xml = builder.build({
testsuites: {
testsuite: {
testcase: testcases,
},
},
});
await fs.writeFile(args.outputPath, xml);
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* After the maestro retry loop completes, this produces a single canonical
* JUnit report at final_report_path matching the bash step's "cp latest
* attempt" semantics.
* Used as a fallback when mergeJUnitReports fails due to data issues but the
* step still needs to produce final_report_path.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
@@ -342,7 +410,8 @@ export async function copyLatestAttemptXml(args: {
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins.
// treated as attempt 0. Ties are broken by sorted filename — later wins
// (same rule as mergeJUnitReports).
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
@@ -11,7 +11,12 @@ import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { copyLatestAttemptXml } from './maestroResultParser';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -30,6 +35,19 @@ function parseInput<S extends z.ZodTypeAny>(
return result.data;
}
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
// fault, so the post-loop merge should fall through to copy-latest instead
// of throwing.
function isFilesystemError(err: any): boolean {
if (!err || typeof err !== 'object') {
return false;
}
const code = err.code;
return (
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
);
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
@@ -79,6 +97,12 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'retry_failed_only',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
@@ -168,6 +192,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
inputs.shards.value,
'shards must be a positive integer.'
);
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
@@ -175,13 +200,26 @@ 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,
});
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
@@ -190,19 +228,25 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
});
logger.info(
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
try {
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowPaths,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
signal,
});
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
@@ -219,26 +263,56 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
break;
}
logger.info('Test failed, retrying...');
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
);
} else {
flowsToRun = flowPaths;
logger.info('Test failed; could not determine failed subset, retrying all flows');
}
} else {
flowsToRun = flowPaths;
logger.info('Test failed, retrying all flows');
}
await sleepAsync(2000);
}
// Copy the latest attempt's JUnit to the final report path so downstream
// upload/report steps have a single canonical file.
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Filesystem errors short-circuit straight to SystemError.
if (finalReportPath !== undefined) {
try {
await copyLatestAttemptXml({
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure usually means maestro itself failed early
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
}
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
@@ -246,7 +320,6 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
@@ -45,6 +45,7 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -1,9 +1,13 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
@@ -38,6 +42,11 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'flow_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
@@ -51,14 +60,26 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
@@ -20,7 +20,7 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -277,9 +277,27 @@ export async function restoreGradleCacheAsync({
logger,
});
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
logger.info(
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
`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 {}
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -1,23 +1,38 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
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 {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
getDeviceRunSessionIdOrThrow,
spawnDetached,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
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(): BuildFunction {
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
@@ -30,21 +45,29 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunctio
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger }, { inputs, env }) => {
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.
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info('Ensuring cloudflared is installed.');
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
logger.info(
packageVersion
@@ -60,13 +83,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunctio
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
@@ -78,28 +100,45 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunctio
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} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
@@ -110,48 +149,68 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunctio
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
env: NodeJS.ProcessEnv;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}
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], {
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;
}
async function ensureBunInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
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 }
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;
}
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
@@ -161,63 +220,27 @@ async function cloneAgentDeviceAsync({
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
}: {
logFile: string;
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
);
}
@@ -240,15 +263,7 @@ async function waitForFileAsync({
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
@@ -260,7 +275,9 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
@@ -0,0 +1,56 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
ensureBrewPackageInstalledAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
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 });
logger.info('Ensuring cloudflared is installed.');
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Preview URL: ${previewUrl}`);
logger.info(`Stream URL: ${streamUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl, streamUrl },
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
@@ -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);
}
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
@@ -17,6 +18,13 @@ jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
@@ -146,9 +154,49 @@ const FIXTURE_NESTED = {
],
};
// Overlapping + gapped intervals with pairwise-distinct totals
// (taskSeconds=11, wallSpan=12, activeWallTime=8). Distinct values catch a
// regression that leaks wallSpan or taskSeconds into the rendered Active cell.
const FIXTURE_OVERLAP_WITH_GAP = {
schema: { name: 'OverlapWithGap' },
subSteps: [
{
title: 'Build target GappedModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 105.0,
signature: 'CompileC a.c',
subSteps: [],
},
{
detailStepType: 'cCompilation',
duration: 4.0,
startTimestamp: 102.0,
endTimestamp: 106.0,
signature: 'CompileC b.c',
subSteps: [],
},
{
detailStepType: 'cCompilation',
duration: 2.0,
startTimestamp: 110.0,
endTimestamp: 112.0,
signature: 'CompileC c.c',
subSteps: [],
},
],
},
],
};
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),
@@ -171,38 +219,45 @@ describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
jest.mocked(Sentry.capture).mockClear();
});
afterEach(() => {
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({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
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.46.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
'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.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
{ stdio: 'pipe', env: TEST_ENV }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
3,
path.join(tempDir, 'xclogparser'),
[
'parse',
@@ -215,15 +270,16 @@ describe('parseAndReportXcactivitylog', () => {
'--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')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
@@ -233,6 +289,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({
@@ -240,18 +297,19 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
@@ -267,6 +325,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);
@@ -276,18 +335,19 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
@@ -300,11 +360,12 @@ describe('parseAndReportXcactivitylog', () => {
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output is invalid', async () => {
it('reports phase "parsing_xclogparser_output" when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -312,18 +373,20 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
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' } }
);
});
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
@@ -333,6 +396,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -340,15 +404,71 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
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' } }
);
});
it('reports phase "running_xclogparser" when the xclogparser invocation fails', async () => {
const logger = createMockLogger();
mockFilesystem();
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
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
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' } }
);
});
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
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' } }
);
});
it('swallows cleanup failures without emitting additional logs', async () => {
@@ -359,6 +479,7 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -366,13 +487,171 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
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',
'--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', () => {
@@ -478,17 +757,38 @@ describe('buildTargetMetrics', () => {
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(3.0);
expect(mod!.wallSpan).toBe(0); // no valid intervals
expect(mod!.activeWallTime).toBe(0); // no valid intervals
});
it('computes activeWallTime by merging overlapping intervals and excluding gaps', () => {
const { results } = buildTargetMetrics(FIXTURE_OVERLAP_WITH_GAP);
const mod = results.find(r => r.moduleName === 'GappedModule');
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(11.0); // 5 + 4 + 2
expect(mod!.wallSpan).toBeCloseTo(12.0); // 112 - 100
expect(mod!.activeWallTime).toBeCloseTo(8.0); // (106-100) + (112-110)
});
});
describe('formatReport', () => {
it('produces table with header and module rows', () => {
it('produces table with header, legend, and module rows', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('ModuleA');
expect(report).toContain('ModuleB');
expect(report).toContain('TOTAL');
expect(report).toContain('% Task');
expect(report).toContain('% Sum');
expect(report).toContain('Sum = sum of compile-step wall durations');
expect(report).toContain('Active = merged compile-step wall time');
expect(report).not.toContain('% Task');
expect(report).not.toContain('Wall = ');
});
it('renders Active using activeWallTime, not wallSpan', () => {
const report = formatReport(FIXTURE_OVERLAP_WITH_GAP);
expect(report).toContain('11.0s');
expect(report).toContain('8.0s');
expect(report).not.toContain('12.0s');
});
it('does not include modules below threshold', () => {
@@ -1,58 +1,88 @@
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';
import path from 'path';
import { z } from 'zod';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
import { Sentry } from '../../../sentry';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
* 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.
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
xclogparserVersion,
logger,
proxyBaseUrl,
env,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<void> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), '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';
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, { tags: { phase } });
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -60,12 +90,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}`;
@@ -73,7 +119,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(
@@ -86,31 +132,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);
@@ -133,11 +183,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);
@@ -154,7 +206,7 @@ async function runXclogparser({
'--output',
outputPath,
],
{ stdio: 'pipe' }
{ stdio: 'pipe', env }
);
return outputPath;
@@ -183,6 +235,7 @@ type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
interface TargetMetric {
moduleName: string;
taskSeconds: number;
// Not rendered; retained as secondary sort tie-breaker in buildTargetMetrics.
wallSpan: number;
activeWallTime: number;
}
@@ -358,19 +411,22 @@ export function formatReport(data: XcactivitylogData): string {
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
lines.push('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push(
'Sum = sum of compile-step wall durations within the target; overlapping steps are counted separately'
);
lines.push('% Sum = share of total Sum');
lines.push('Active = merged compile-step wall time, excluding idle gaps between compile steps');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Task'.padStart(taskWidth) +
'Sum'.padStart(taskWidth) +
' │ ' +
'% Task'.padStart(pctWidth) +
'% Sum'.padStart(pctWidth) +
' │ ' +
'Wall'.padStart(wallWidth) +
'Active'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -390,7 +446,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.wallSpan).padStart(wallWidth) +
formatSeconds(result.activeWallTime).padStart(wallWidth) +
' │ ' +
bar +
' │'
@@ -406,7 +462,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' '.repeat(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -0,0 +1,159 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
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, ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
return deviceRunSessionId;
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
);
}
}
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;
};
export function spawnDetached({
command,
args,
cwd,
env,
}: {
command: string;
args: string[];
cwd?: string;
env: BuildStepEnv;
}): DetachedProcessHandle {
const promise = spawn(command, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
// We don't await the process — it should outlive this step. Failures show
// up in the captured output; suppress unhandled rejections here.
promise.catch(() => {});
promise.child.unref();
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
output += chunk.toString();
};
promise.child.stdout?.on('data', appendChunk);
promise.child.stderr?.on('data', appendChunk);
return { getOutput: () => output };
}
export async function startServeSimWithTunnelAsync({
env,
logger,
timeoutMs,
}: {
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const serveSim = spawnDetached({
command: 'npx',
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-protocol',
'quic',
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
],
env,
});
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl(output, 'Tunnel');
const streamUrl = matchLabeledUrl(output, 'Stream');
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
);
}
function matchLabeledUrl(content: string, label: string): string | null {
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
const match = labelPattern.exec(content);
return match ? match[1] : null;
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.9.0",
"version": "19.0.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.9.0"
"@expo/steps": "19.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.5.0",
"version": "19.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": "19.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",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.8.0",
"version": "19.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,7 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "19.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -266,6 +266,9 @@ describe('Android.JobSchema', () => {
customBuildConfig: {
path: 'production.android.yml',
},
outputs: {
android_build_type: 'apk',
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -340,6 +343,28 @@ describe('Android.JobSchema', () => {
expect(error).toBeFalsy();
});
test('preserves outputs for build jobs', () => {
const job = {
mode: BuildMode.BUILD,
type: Workflow.GENERIC,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
outputs: {
android_build_type: 'apk',
},
secrets,
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Android.JobSchema.validate(job, joiOptions);
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('can set hooks with arbitrary names', () => {
const job = {
mode: BuildMode.BUILD,
@@ -77,6 +77,9 @@ describe('Ios.JobSchema', () => {
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
outputs: {
resign_job: 'true',
},
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
@@ -180,6 +183,9 @@ describe('Ios.JobSchema', () => {
customBuildConfig: {
path: 'production.ios.yml',
},
outputs: {
ios_build_type: 'simulator',
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -254,6 +260,30 @@ describe('Ios.JobSchema', () => {
expect(error).toBeFalsy();
});
test('preserves outputs for build jobs', () => {
const job = {
mode: BuildMode.BUILD,
type: Workflow.UNKNOWN,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
outputs: {
ios_build_type: 'simulator',
},
secrets: {
buildCredentials,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('invalid generic job', () => {
const genericJob = {
secrets: {
@@ -0,0 +1,77 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { errors } from '..';
import { getInstalledExpoPackageVersionAsync } from '../projectPackage';
describe(getInstalledExpoPackageVersionAsync, () => {
it('returns the installed expo package version resolved by project node', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).resolves.toBe('55.0.17');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo cannot be resolved from the project', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo package.json cannot be read', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('read failed'));
const promise = getInstalledExpoPackageVersionAsync({ projectDir });
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
});
await expect(promise).rejects.toBeInstanceOf(errors.UserError);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when the installed expo package version is not valid semver', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: 'invalid-version' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});
+4 -1
View File
@@ -15,6 +15,8 @@ import {
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
JobOutputs,
JobOutputsSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -110,7 +112,7 @@ export interface Job {
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
outputs?: JobOutputs;
experimental?: {
prebuildCommand?: string;
@@ -176,6 +178,7 @@ export const JobSchema = Joi.object({
buildType: Joi.string().valid(...Object.values(BuildType)),
username: Joi.string(),
hooks: HooksSchema,
outputs: JobOutputsSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
+4 -3
View File
@@ -352,6 +352,10 @@ export type DynamicInterpolationContext = {
export type WorkflowInterpolationContext = StaticWorkflowInterpolationContext &
DynamicInterpolationContext;
export type JobOutputs = Record<string, string>;
export const JobOutputsSchema = Joi.object<JobOutputs>().pattern(Joi.string(), Joi.string());
export const JobOutputsSchemaZ = z.record(z.string(), z.string());
export const CustomBuildConfigSchema = Joi.object().when('.mode', {
is: [BuildMode.CUSTOM, BuildMode.REPACK],
then: Joi.object().when('.customBuildConfig.path', {
@@ -361,7 +365,6 @@ export const CustomBuildConfigSchema = Joi.object().when('.mode', {
path: Joi.string().required(),
}).required(),
steps: Joi.any().strip(),
outputs: Joi.any().strip(),
}),
otherwise: Joi.object({
customBuildConfig: Joi.any().strip(),
@@ -369,13 +372,11 @@ export const CustomBuildConfigSchema = Joi.object().when('.mode', {
.items(Joi.any())
.required()
.custom(steps => validateSteps(steps), 'steps validation'),
outputs: Joi.object().pattern(Joi.string(), Joi.string()).required(),
}),
}),
otherwise: Joi.object({
customBuildConfig: Joi.any().strip(),
steps: Joi.any().strip(),
outputs: Joi.any().strip(),
}),
});
+2 -1
View File
@@ -6,6 +6,7 @@ import {
BuildTrigger,
EnvironmentSecretZ,
HooksZ,
JobOutputsSchemaZ,
StaticWorkflowInterpolationContextZ,
} from './common';
import { StepZ } from './step';
@@ -48,7 +49,7 @@ export namespace Generic {
hooks: HooksZ.optional(),
steps: z.array(StepZ).min(1),
outputs: z.record(z.string(), z.string()).optional(),
outputs: JobOutputsSchemaZ.optional(),
});
export type PartialJob = z.infer<typeof PartialJobZ>;
+1
View File
@@ -27,6 +27,7 @@ export * from './context';
export * from './generic';
export * from './step';
export * from './submission-config';
export * from './projectPackage';
const version = require('../package.json').version;
export { version };
+4 -1
View File
@@ -15,6 +15,8 @@ import {
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
JobOutputs,
JobOutputsSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -124,7 +126,7 @@ export interface Job {
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
outputs?: JobOutputs;
experimental?: {
prebuildCommand?: string;
@@ -210,6 +212,7 @@ export const JobSchema = Joi.object({
username: Joi.string(),
hooks: HooksSchema,
outputs: JobOutputsSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
+3
View File
@@ -36,6 +36,7 @@ export enum BuildPhase {
// ANDROID
FIX_GRADLEW = 'FIX_GRADLEW',
RUN_GRADLEW = 'RUN_GRADLEW',
GRADLE_BUILD_PROFILE = 'GRADLE_BUILD_PROFILE',
// IOS
INSTALL_PODS = 'INSTALL_PODS',
@@ -104,6 +105,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'Fix gradlew',
[BuildPhase.RUN_GRADLEW]: 'Run gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'Gradle build profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'Install pods',
@@ -169,6 +171,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'fix-gradlew',
[BuildPhase.RUN_GRADLEW]: 'run-gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'gradle-build-profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'install-pods',
+6
View File
@@ -49,6 +49,11 @@ export type Metadata = {
*/
sdkVersion?: string;
/**
* Version of the expo package used in the project.
*/
expoPackageVersion?: string;
/**
* Runtime version (for Expo Updates)
*/
@@ -180,6 +185,7 @@ export const MetadataSchema = Joi.object({
distribution: Joi.string().valid('store', 'internal', 'simulator'),
credentialsSource: Joi.string().valid('local', 'remote'),
sdkVersion: Joi.string(),
expoPackageVersion: Joi.string(),
runtimeVersion: Joi.string(),
fingerprintHash: Joi.string(),
reactNativeVersion: Joi.string(),
@@ -0,0 +1,54 @@
import spawn from '@expo/turtle-spawn';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import semver from 'semver';
import { type Env } from './common';
import * as errors from './errors';
export async function getInstalledExpoPackageVersionAsync({
env = process.env,
projectDir,
}: {
env?: Env | NodeJS.ProcessEnv;
projectDir: string;
}): Promise<string> {
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: projectDir,
env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(readJsonAsync(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}
async function readJsonAsync(filePath: string): Promise<any> {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
}
+1 -1
View File
@@ -50,7 +50,7 @@ yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
The GraphQL schema is fetched from the API URL resolved by `src/api.ts` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
+234 -99
View File
@@ -120,6 +120,13 @@ eas --help COMMAND
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
* [`eas integrations:convex:connect`](#eas-integrationsconvexconnect)
* [`eas integrations:convex:dashboard`](#eas-integrationsconvexdashboard)
* [`eas integrations:convex:project`](#eas-integrationsconvexproject)
* [`eas integrations:convex:project:delete`](#eas-integrationsconvexprojectdelete)
* [`eas integrations:convex:team`](#eas-integrationsconvexteam)
* [`eas integrations:convex:team:delete [CONVEX_TEAM]`](#eas-integrationsconvexteamdelete-convex_team)
* [`eas integrations:convex:team:invite [CONVEX_TEAM]`](#eas-integrationsconvexteaminvite-convex_team)
* [`eas login`](#eas-login)
* [`eas logout`](#eas-logout)
* [`eas metadata:lint`](#eas-metadatalint)
@@ -173,8 +180,8 @@ USAGE
$ eas account:login [-s] [-b]
FLAGS
-b, --browser Login with your browser
-s, --sso Login with SSO
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
DESCRIPTION
log in with your Expo account
@@ -183,7 +190,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -200,7 +207,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -222,7 +229,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -239,7 +246,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -253,7 +260,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -305,7 +312,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -326,7 +333,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -346,7 +353,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -366,7 +373,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -389,7 +396,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -427,7 +434,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -447,7 +454,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -465,7 +472,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -485,7 +492,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -493,13 +500,16 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
```
USAGE
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler] [--simulator
<value>]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
emulator/simulator internal distribution dev client builds. The "development-simulator"
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
--simulator=<value> iOS simulator name or UDID to install and run the development build on. If no value is
provided, you will be prompted to select a simulator.
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
--skip-bundler Install and run the development build without starting the bundler server.
@@ -507,7 +517,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -534,7 +544,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -572,7 +582,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -612,7 +622,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -642,7 +652,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -651,7 +661,7 @@ run simulator/emulator builds from eas-cli
```
USAGE
$ eas build:run [--latest | --id <value> | --path <value> | --url <value>] [-p android|ios] [-e PROFILE_NAME]
[--offset <value>] [--limit <value>]
[--simulator <value>] [--offset <value>] [--limit <value>]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile used to create the build to run. When specified, only builds
@@ -662,13 +672,15 @@ FLAGS
--limit=<value> The number of items to fetch each query. Defaults to 50 and is capped at 100.
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
--path=<value> Path to the simulator/emulator build archive or app
--simulator=<value> iOS simulator name or UDID to install and run the build on. If no value is provided, you
will be prompted to select a simulator.
--url=<value> Simulator/Emulator build archive url
DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -720,7 +732,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -738,7 +750,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -756,7 +768,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -773,7 +785,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -794,7 +806,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -815,7 +827,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -837,7 +849,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -861,7 +873,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -881,7 +893,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -903,7 +915,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -925,7 +937,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -960,7 +972,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -983,7 +995,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1004,7 +1016,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1021,7 +1033,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1039,7 +1051,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1068,7 +1080,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1093,7 +1105,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1114,7 +1126,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1135,7 +1147,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1172,7 +1184,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1192,7 +1204,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1213,7 +1225,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1234,7 +1246,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1248,7 +1260,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1262,7 +1274,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1294,7 +1306,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1320,7 +1332,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1342,7 +1354,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1370,7 +1382,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1398,7 +1410,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1421,7 +1433,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1443,7 +1455,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1477,7 +1489,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1521,7 +1533,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1552,7 +1564,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1631,7 +1643,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1650,7 +1662,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1668,7 +1680,130 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
connect Convex to your Expo project
```
USAGE
$ eas integrations:convex:connect [--non-interactive] [--region aws-us-east-1|aws-eu-west-1] [--team-name <value>]
[--project-name <value>]
FLAGS
--non-interactive Run the command in non-interactive mode.
--project-name=<value> Name for the Convex project (defaults to app slug)
--region=<option> Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)
<options: aws-us-east-1|aws-eu-west-1>
--team-name=<value> Name for the new Convex team (defaults to EAS account name)
DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
open the Convex dashboard for the linked Convex project
```
USAGE
$ eas integrations:convex:dashboard
DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
display the Convex project linked to the current Expo app
```
USAGE
$ eas integrations:convex:project
DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
remove the Convex project link for the current Expo app from EAS servers
```
USAGE
$ eas integrations:convex:project:delete [--non-interactive] [-y]
FLAGS
-y, --yes Skip confirmation prompt
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
display Convex teams linked to the current Expo app's owner account
```
USAGE
$ eas integrations:convex:team
DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
remove a Convex team link from the current Expo app owner account's EAS servers
```
USAGE
$ eas integrations:convex:team:delete [CONVEX_TEAM] [--non-interactive] [-y]
ARGUMENTS
[CONVEX_TEAM] Slug of the Convex team to remove
FLAGS
-y, --yes Skip confirmation prompt
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
send a Convex team invitation to your verified email address
```
USAGE
$ eas integrations:convex:team:invite [CONVEX_TEAM] [--non-interactive]
ARGUMENTS
[CONVEX_TEAM] Slug of the Convex team to invite yourself to
FLAGS
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas login`
@@ -1679,8 +1814,8 @@ USAGE
$ eas login [-s] [-b]
FLAGS
-b, --browser Login with your browser
-s, --sso Login with SSO
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
-s, --sso Log in with SSO
DESCRIPTION
log in with your Expo account
@@ -1720,7 +1855,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1738,7 +1873,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1756,7 +1891,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1808,7 +1943,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1822,7 +1957,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1845,7 +1980,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1869,7 +2004,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1887,7 +2022,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1920,7 +2055,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1963,7 +2098,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1984,7 +2119,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2005,7 +2140,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2030,7 +2165,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -2057,7 +2192,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2084,7 +2219,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2122,7 +2257,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2150,7 +2285,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2180,7 +2315,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2201,7 +2336,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2225,7 +2360,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2246,7 +2381,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2268,7 +2403,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2288,7 +2423,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2307,7 +2442,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2330,7 +2465,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2347,7 +2482,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2468,7 +2603,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2488,7 +2623,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2512,7 +2647,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2552,7 +2687,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2574,7 +2709,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2602,7 +2737,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2622,7 +2757,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2644,5 +2779,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+28
View File
@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import { getExpoApiBaseUrl } from './src/api';
const schema =
process.env.GRAPHQL_SCHEMA_URL ?? new URL('/graphql', getExpoApiBaseUrl()).toString();
const config: CodegenConfig = {
overwrite: true,
schema,
documents: ['src/**/!(*.d).ts'],
generates: {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
dedupeOperationSuffix: true,
},
hooks: {
afterOneFileWrite: ['node ./scripts/annotate-graphql-codegen.js'],
},
},
'./graphql.schema.json': {
plugins: ['introspection'],
},
},
};
export default config;
-17
View File
@@ -1,17 +0,0 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
./graphql.schema.json:
plugins:
- 'introspection'
+3394 -9
View File
File diff suppressed because it is too large. Load diff
+12 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.9.1",
"version": "19.0.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -37,7 +37,7 @@
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
@@ -47,11 +47,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-json": "19.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.0",
"@expo/logger": "19.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.9.0",
"@expo/steps": "19.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -78,6 +78,7 @@
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
"debug": "4.4.3",
"diff": "7.0.0",
"dotenv": "16.3.1",
"env-paths": "2.2.0",
@@ -138,6 +139,7 @@
"@tsconfig/node20": "20.1.8",
"@types/cli-progress": "3.11.5",
"@types/dateformat": "3.0.1",
"@types/debug": "4.1.13",
"@types/diff": "6.0.0",
"@types/envinfo": "7.8.3",
"@types/express": "4.17.15",
@@ -202,7 +204,7 @@
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage service connections"
"description": "manage third-party service integrations"
},
"metadata": {
"description": "manage store configuration"
@@ -219,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"update": {
"description": "manage individual updates"
},
+346
View File
@@ -0,0 +1,346 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
/*
* Manual Convex integration E2E runner.
*
* Run from packages/eas-cli with:
* yarn ts-node scripts/convex-e2e.ts
*
* This uses the deployed EAS API and real authenticated EAS/Convex-backed
* resources. Cleanup removes EAS-side links when possible, but it does not
* destroy Convex-side resources.
*
* Options:
* CONVEX_E2E_KEEP_PROJECT=1 keeps the temp Expo project and skips cleanup.
* CONVEX_E2E_SKIP_DASHBOARD=1 skips opening the Convex dashboard.
* CONVEX_E2E_ACCOUNT=<account-name> sets the Expo app owner before eas init.
*/
type RunOptions = {
cwd?: string;
allowFailure?: boolean;
env?: NodeJS.ProcessEnv;
};
type RunResult = {
stdout: string;
stderr: string;
exitCode: number | null;
};
const KEEP_PROJECT = process.env.CONVEX_E2E_KEEP_PROJECT === '1';
const SKIP_DASHBOARD = process.env.CONVEX_E2E_SKIP_DASHBOARD === '1';
const ACCOUNT = process.env.CONVEX_E2E_ACCOUNT;
const SCRIPT_DIR = __dirname;
const EAS_CLI_ROOT = path.resolve(SCRIPT_DIR, '..');
const LOCAL_EAS_BIN = path.join(EAS_CLI_ROOT, 'bin', 'run');
const TEMP_ROOT_PREFIX = path.join(os.tmpdir(), 'eas-cli-convex-e2e-');
let tempRoot: string | null = null;
let projectDir: string | null = null;
let initializedProject = false;
let initialConvexTeamSlugs: string[] = [];
let createdConvexTeamSlug: string | null = null;
async function mainAsync(): Promise<void> {
try {
await assertFileExistsAsync(
LOCAL_EAS_BIN,
`Local EAS CLI binary not found at ${LOCAL_EAS_BIN}`
);
await runEasAsync(['whoami']);
const testName = `convex-e2e-${Date.now().toString(36)}`;
tempRoot = await fs.mkdtemp(TEMP_ROOT_PREFIX);
projectDir = path.join(tempRoot, testName);
logStep(`Creating fresh Expo project ${testName}`);
await runAsync('npx', ['--yes', 'create-expo-app@latest', projectDir, '--template', 'blank'], {
cwd: tempRoot,
env: getNpxEnvironment(),
});
await updateAppJsonAsync(projectDir, testName);
logStep('Creating/linking EAS project');
await runEasAsync(['init', '--non-interactive', '--force'], projectDir);
initializedProject = true;
initialConvexTeamSlugs = parseConvexTeamSlugs(
(await runEasAsync(['integrations:convex:team'], projectDir)).stdout
);
logStep('Connecting Convex');
await runEasAsync(
['integrations:convex:connect', '--non-interactive', '--project-name', testName],
projectDir
);
await assertEnvLocalAsync(projectDir);
await assertPackageJsonDependencyAsync(projectDir, 'convex');
const teamOutput = await runEasAsync(['integrations:convex:team'], projectDir);
const projectOutput = await runEasAsync(['integrations:convex:project'], projectDir);
assertIncludes(
projectOutput.stdout,
'Dashboard',
'Expected project output to include dashboard'
);
const teamSlugsAfterConnect = parseConvexTeamSlugs(teamOutput.stdout);
const newTeamSlugs = teamSlugsAfterConnect.filter(
slug => !initialConvexTeamSlugs.includes(slug)
);
createdConvexTeamSlug = newTeamSlugs[0] ?? null;
const convexTeamSlug = createdConvexTeamSlug ?? teamSlugsAfterConnect[0];
if (!convexTeamSlug) {
throw new Error('Could not determine a Convex team slug from team output.');
}
await runEasAsync(
['integrations:convex:team:invite', convexTeamSlug, '--non-interactive'],
projectDir
);
if (SKIP_DASHBOARD) {
logStep('Skipping dashboard command because CONVEX_E2E_SKIP_DASHBOARD=1');
} else {
await runEasAsync(['integrations:convex:dashboard'], projectDir);
}
logStep('Convex E2E flow completed successfully');
} finally {
await cleanupAsync();
}
}
async function cleanupAsync(): Promise<void> {
if (!projectDir || !tempRoot) {
return;
}
if (KEEP_PROJECT) {
logStep('Skipping cleanup because CONVEX_E2E_KEEP_PROJECT=1');
log(`Temp project kept at: ${projectDir}`);
log('EAS-side links and Convex-side resources were left intact.');
return;
}
if (initializedProject) {
logStep('Cleaning up EAS-side Convex links');
const convexTeamSlugToDelete =
createdConvexTeamSlug ?? (await getCreatedConvexTeamSlugForCleanupAsync());
await runCleanupCommandAsync([
'integrations:convex:project:delete',
'--non-interactive',
'--yes',
]);
if (convexTeamSlugToDelete) {
await runCleanupCommandAsync([
'integrations:convex:team:delete',
convexTeamSlugToDelete,
'--non-interactive',
'--yes',
]);
} else {
warn(
'No newly-created Convex team link was detected. Skipping team link deletion to avoid removing a pre-existing EAS-side link.'
);
}
warn('Convex-side resources are not destroyed by this cleanup.');
}
await fs.rm(tempRoot, { recursive: true, force: true });
log(`Removed temp project directory: ${tempRoot}`);
}
async function getCreatedConvexTeamSlugForCleanupAsync(): Promise<string | null> {
if (!projectDir) {
return null;
}
const result = await runEasAsync(['integrations:convex:team'], projectDir, {
allowFailure: true,
});
if (result.exitCode !== 0) {
warn('Could not inspect Convex team links during cleanup.');
return null;
}
return (
parseConvexTeamSlugs(result.stdout).find(slug => !initialConvexTeamSlugs.includes(slug)) ?? null
);
}
async function runCleanupCommandAsync(args: string[]): Promise<void> {
try {
const result = await runEasAsync(args, projectDir ?? undefined, { allowFailure: true });
if (result.exitCode !== 0) {
warn(`Cleanup command exited with ${result.exitCode}: ${args.join(' ')}`);
}
} catch (error) {
warn(`Cleanup command failed: ${args.join(' ')}`);
warn(error instanceof Error ? error.message : String(error));
}
}
async function runEasAsync(
args: string[],
cwd?: string,
options: RunOptions = {}
): Promise<RunResult> {
return await runAsync(LOCAL_EAS_BIN, args, { cwd, ...options });
}
async function runAsync(
command: string,
args: string[],
{ cwd, allowFailure = false, env = {} }: RunOptions = {}
): Promise<RunResult> {
const printableCommand = [command, ...args].join(' ');
log(`\n$ ${printableCommand}`);
if (cwd) {
log(`cwd: ${cwd}`);
}
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
const chunk = data.toString();
stdout += chunk;
process.stdout.write(redact(chunk));
});
child.stderr.on('data', data => {
const chunk = data.toString();
stderr += chunk;
process.stderr.write(redact(chunk));
});
child.on('error', reject);
child.on('close', exitCode => {
const result = { stdout, stderr, exitCode };
if (!allowFailure && exitCode !== 0) {
reject(new Error(`Command failed with exit code ${exitCode}: ${printableCommand}`));
return;
}
resolve(result);
});
});
}
async function updateAppJsonAsync(appDir: string, testName: string): Promise<void> {
const appJsonPath = path.join(appDir, 'app.json');
const rawAppJson = await fs.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(rawAppJson);
appJson.expo = {
...appJson.expo,
name: testName,
slug: testName,
...(ACCOUNT ? { owner: ACCOUNT } : {}),
};
await fs.writeFile(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`);
}
async function assertEnvLocalAsync(appDir: string): Promise<void> {
const envLocalPath = path.join(appDir, '.env.local');
const envLocal = await fs.readFile(envLocalPath, 'utf8');
assertIncludes(
envLocal,
'CONVEX_DEPLOY_KEY=',
'Expected .env.local to contain CONVEX_DEPLOY_KEY'
);
assertIncludes(
envLocal,
'EXPO_PUBLIC_CONVEX_URL=',
'Expected .env.local to contain EXPO_PUBLIC_CONVEX_URL'
);
log('\nVerified .env.local contains Convex environment variables:');
log(redact(envLocal));
}
async function assertPackageJsonDependencyAsync(
appDir: string,
packageName: string
): Promise<void> {
const packageJsonPath = path.join(appDir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (!dependencies[packageName]) {
throw new Error(`Expected package.json to include ${packageName}`);
}
log(`Verified package.json includes ${packageName}@${dependencies[packageName]}`);
}
function parseConvexTeamSlugs(output: string): string[] {
const strippedOutput = stripAnsi(output);
return Array.from(strippedOutput.matchAll(/^Team:\s+.+?\s\/\s(.+)$/gm), match => match[1].trim());
}
function assertIncludes(value: string, expected: string, message: string): void {
if (!value.includes(expected)) {
throw new Error(message);
}
}
async function assertFileExistsAsync(filePath: string, message: string): Promise<void> {
try {
await fs.access(filePath);
} catch {
throw new Error(message);
}
}
function logStep(message: string): void {
log(`\n==> ${message}`);
}
function log(message: string): void {
process.stdout.write(`${message}\n`);
}
function warn(message: string): void {
process.stderr.write(`${message}\n`);
}
function redact(value: string): string {
return value
.replace(/^CONVEX_DEPLOY_KEY=.*$/gm, 'CONVEX_DEPLOY_KEY=<redacted>')
.replace(/dev:[^\s|]+\|[^\s]+/g, 'dev:<redacted>');
}
function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-9;]*m/g, '');
}
function getNpxEnvironment(): NodeJS.ProcessEnv {
return {
npm_config_user_agent: `npm/10 node/${process.versions.node} ${process.platform} ${process.arch}`,
npm_execpath: undefined,
};
}
mainAsync().catch(error => {
warn(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -1,377 +1,81 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { CommonContext } from '../context';
import { checkLockfileAsync } from '../validateLockfile';
import { Client } from '../../vcs/vcs';
import { ensureLockfileExistsAsync } from '../validateLockfile';
jest.mock('fs');
jest.mock('@expo/package-manager', () => ({
NPM_LOCK_FILE: 'package-lock.json',
YARN_LOCK_FILE: 'yarn.lock',
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
BUN_LOCK_FILE: 'bun.lockb',
BUN_TEXT_LOCK_FILE: 'bun.lock',
resolveWorkspaceRoot: jest.fn(() => null),
}));
const { resolveWorkspaceRoot } = jest.requireMock<{
resolveWorkspaceRoot: jest.Mock;
}>('@expo/package-manager');
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
function createMockContext(
overrides: Partial<{
projectDir: string;
requiredPackageManager: string | null;
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
getRootPathAsync: () => Promise<string>;
}> = {}
): CommonContext<Platform> {
const vcsClient = {
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
} as unknown as Client;
const mockResolveWorkspaceRoot = jest.fn();
jest.mock('@expo/package-manager', () => {
const actual = jest.requireActual('@expo/package-manager');
return {
projectDir: overrides.projectDir ?? '/project',
requiredPackageManager: overrides.requiredPackageManager ?? null,
vcsClient,
} as unknown as CommonContext<Platform>;
}
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
...actual,
resolveWorkspaceRoot: (...args: unknown[]) => mockResolveWorkspaceRoot(...args),
};
});
const NPM_LOCKFILE_SYNCED = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
},
});
const NPM_LOCKFILE_STALE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-native': { version: '0.79.4' },
},
});
// bun.lock uses JSONC with trailing commas
const BUN_LOCKFILE_STALE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const BUN_LOCKFILE_SYNCED = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
[email protected]:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
`;
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
beforeEach(() => {
vol.reset();
resolveWorkspaceRoot.mockReturnValue(null);
mockProcessExit.mockClear();
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
mockResolveWorkspaceRoot.mockReturnValue(null);
});
describe(checkLockfileAsync, () => {
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
// This would normally fail — no lockfile, no package manager
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
describe(ensureLockfileExistsAsync, () => {
it('passes when package-lock.json exists', async () => {
vol.fromJSON({ './package-lock.json': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('exits when no package manager is detected', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
it('passes when yarn.lock exists', async () => {
vol.fromJSON({ './yarn.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('exits when npm is detected but package-lock.json is missing', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
it('passes when pnpm-lock.yaml exists', async () => {
vol.fromJSON({ './pnpm-lock.yaml': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('does not error when lockfile exists and is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
it('passes when bun.lockb exists', async () => {
vol.fromJSON({ './bun.lockb': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('exits when lockfile exists but is gitignored', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({
requiredPackageManager: 'yarn',
isFileIgnoredAsync: async () => true,
});
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
it('passes when bun.lock exists', async () => {
vol.fromJSON({ './bun.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('exits when conflicting lockfiles from multiple managers exist', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
it('throws when no lockfile exists', async () => {
vol.fromJSON({ './package.json': '{}' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).rejects.toThrow(
'No lockfile found in the project directory.'
);
});
it('exits when npm lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
it('passes when lockfile exists in workspace root', async () => {
vol.fromJSON(
{
'./packages/my-app/package.json': '{}',
'./yarn.lock': '',
},
'/monorepo'
);
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
await expect(
ensureLockfileExistsAsync('/monorepo/packages/my-app')
).resolves.not.toThrow();
});
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when bun.lock is in sync', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/yarn.lock': YARN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when yarn lockfile is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('finds lockfile at workspace root for monorepos', async () => {
vol.fromJSON({
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
});
resolveWorkspaceRoot.mockReturnValue('/monorepo');
const ctx = createMockContext({
projectDir: '/monorepo/packages/app',
requiredPackageManager: 'yarn',
getRootPathAsync: async () => '/monorepo',
});
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lockb': 'binary',
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips sync check for non-registry specifiers', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: {
'my-local-pkg': 'file:../local',
'my-workspace-pkg': 'workspace:*',
react: '19.0.4',
},
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
describe('peer dependency conflicts', () => {
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when peer dependencies are satisfied', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.2.4' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (bun optionalPeers)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
"packages": {
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('throws when no lockfile in project dir or workspace root', async () => {
vol.fromJSON(
{
'./packages/my-app/package.json': '{}',
'./package.json': '{}',
},
'/monorepo'
);
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
await expect(ensureLockfileExistsAsync('/monorepo/packages/my-app')).rejects.toThrow(
'No lockfile found in the project directory.'
);
});
});
+19 -4
View File
@@ -1,5 +1,11 @@
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import {
Metadata,
Platform,
getInstalledExpoPackageVersionAsync,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
@@ -9,9 +15,7 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import {
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { isExpoUpdatesInstalled } from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -26,6 +30,16 @@ export async function collectMetadataAsync<T extends Platform>(
): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx);
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE;
const expoPackageVersionResult = await asyncResult(
getInstalledExpoPackageVersionAsync({
env: process.env,
projectDir: ctx.projectDir,
})
);
if (!expoPackageVersionResult.ok) {
Log.debug('Failed to resolve expo package version:');
Log.debug(expoPackageVersionResult.reason);
}
const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)),
@@ -36,6 +50,7 @@ export async function collectMetadataAsync<T extends Platform>(
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
expoPackageVersion: expoPackageVersionResult.value,
...channelObject,
distribution,
appName: ctx.exp.name,
@@ -22,8 +22,8 @@ import { createBuildContextAsync } from './createContext';
import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureLockfileExistsAsync } from './validateLockfile';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { checkLockfileAsync } from './validateLockfile';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager';
@@ -66,6 +66,7 @@ import {
} from '../project/remoteVersionSource';
import { confirmAsync } from '../prompts';
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run';
import { SimulatorRunTarget } from '../run/ios/run';
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
import { createSubmissionContextAsync } from '../submit/context';
import {
@@ -104,6 +105,7 @@ export interface BuildFlags {
freezeCredentials: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
}
export async function runBuildAndSubmitAsync({
@@ -416,8 +418,10 @@ async function prepareAndStartBuildAsync({
env,
});
if (!lockfileChecked) {
await checkLockfileAsync(buildCtx);
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
if (!process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
await ensureLockfileExistsAsync(projectDir);
}
lockfileChecked = true;
}
@@ -586,14 +590,17 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
}
}
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
export async function downloadAndRunAsync(
build: BuildFragment,
simulator?: SimulatorRunTarget
): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
if (await pathExists(cachedAppPath)) {
Log.newLine();
Log.log(`Using cached app...`);
await runAsync(cachedAppPath, build.platform);
await runAsync(cachedAppPath, build.platform, simulator);
return;
}
@@ -602,7 +609,7 @@ export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
build.platform,
cachedAppPath
);
await runAsync(buildPath, build.platform);
await runAsync(buildPath, build.platform, simulator);
}
async function maybeDownloadAndRunSimulatorBuildsAsync(
@@ -624,9 +631,9 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
} build on ${
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
}?`,
}));
}));
if (confirm) {
await downloadAndRunAsync(simBuild);
await downloadAndRunAsync(simBuild, flags.simulator);
}
}
}
@@ -0,0 +1,75 @@
import fs from 'fs-extra';
import path from 'path';
import * as tar from 'tar';
import { Client } from '../../../vcs/vcs';
import { makeProjectTarballAsync } from '../repository';
jest.mock('../../../ora', () => ({
ora: jest.fn(() => ({
fail: jest.fn(),
isSpinning: false,
start: jest.fn(),
succeed: jest.fn(),
})),
}));
class FakeVcsClient extends Client {
public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
await fs.mkdirp(path.join(destinationPath, 'bin'));
await fs.mkdirp(path.join(destinationPath, 'read-only-dir'));
await fs.writeFile(path.join(destinationPath, 'bin', 'postcheckout.sh'), '#!/bin/sh\necho hi\n');
await fs.writeFile(path.join(destinationPath, 'regular-file.txt'), 'regular file\n');
await fs.writeFile(path.join(destinationPath, 'read-only-dir', 'child.txt'), 'child file\n');
await fs.chmod(path.join(destinationPath, 'bin', 'postcheckout.sh'), 0o755);
await fs.chmod(path.join(destinationPath, 'regular-file.txt'), 0o644);
await fs.chmod(path.join(destinationPath, 'read-only-dir'), 0o555);
}
public async getRootPathAsync(): Promise<string> {
return process.cwd();
}
public canGetLastCommitMessage(): boolean {
return false;
}
}
describe(makeProjectTarballAsync, () => {
it('creates portable project archives while preserving executable files', async () => {
const removeAsync = fs.remove.bind(fs);
const removeSpy = jest.spyOn(fs, 'remove').mockImplementation(async targetPath => {
await fs.chmod(path.join(targetPath.toString(), 'read-only-dir'), 0o755).catch(() => {});
await removeAsync(targetPath);
});
const projectTarball = await makeProjectTarballAsync(new FakeVcsClient());
const entries = new Map<string, tar.ReadEntry>();
try {
await tar.list({
file: projectTarball.path,
onentry: entry => {
entries.set(entry.path, entry);
},
});
expect(entries.get('project/bin/postcheckout.sh')?.mode).toBe(0o755);
expect(entries.get('project/regular-file.txt')?.mode).toBe(0o644);
expect(entries.get('project/read-only-dir/')?.mode).toBe(0o755);
expect(entries.has('project/read-only-dir/child.txt')).toBe(true);
for (const entry of entries.values()) {
expect(entry.path.startsWith('project/')).toBe(true);
expect(entry.uid).toBeUndefined();
expect(entry.gid).toBeUndefined();
expect(entry.uname).toBe('');
expect(entry.gname).toBe('');
}
} finally {
await fs.remove(projectTarball.path);
removeSpy.mockRestore();
}
});
});
+18 -3
View File
@@ -143,9 +143,24 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
try {
await vcsClient.makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
await tar.create(
{
cwd: shallowClonePath,
file: tarPath,
prefix: 'project',
gzip: true,
portable: true,
onWriteEntry(entry) {
// Read-only directories may have files inside them. This causes trouble on tar extraction.
// Hence, we're forcing the owner write bit on directories in Windows
// to avoid this issue.
if (entry.type === 'Directory' && entry.stat) {
entry.stat.mode |= 0o200;
}
},
},
['.']
);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
+29 -213
View File
@@ -6,226 +6,42 @@ import {
YARN_LOCK_FILE,
resolveWorkspaceRoot,
} from '@expo/package-manager';
import { Platform } from '@expo/eas-build-job';
import {
LockfileData,
findLockfileMismatches,
findPeerDependencyConflicts,
parseLockfile,
} from '@expo/lockfile-utils';
import fs from 'fs-extra';
import { pathExists } from 'fs-extra';
import path from 'path';
import { CommonContext } from './context';
import Log from '../log';
const SKIP_HINT =
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
interface LockfileInfo {
/** Filename of the lockfile (e.g. 'yarn.lock') */
filename: string;
/** Package manager name for display (e.g. 'yarn') */
managerName: string;
}
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
};
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
{ filename: NPM_LOCK_FILE, manager: 'npm' },
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
{ filename: BUN_LOCK_FILE, manager: 'bun' },
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
const LOCKFILE_NAMES = [
NPM_LOCK_FILE,
YARN_LOCK_FILE,
PNPM_LOCK_FILE,
BUN_LOCK_FILE,
BUN_TEXT_LOCK_FILE,
];
/**
* Find the lockfile for the given filename by checking the workspace root first
* (for monorepos), then the project directory.
*/
async function findLockfileAsync(
projectDir: string,
filename: string
): Promise<string | null> {
async function hasLockfileAsync(dir: string): Promise<boolean> {
for (const lockfile of LOCKFILE_NAMES) {
if (await pathExists(path.join(dir, lockfile))) {
return true;
}
}
return false;
}
export async function ensureLockfileExistsAsync(projectDir: string): Promise<void> {
if (await hasLockfileAsync(projectDir)) {
return;
}
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot) {
const workspaceLockfile = path.join(workspaceRoot, filename);
if (await fs.pathExists(workspaceLockfile)) {
return workspaceLockfile;
if (workspaceRoot && workspaceRoot !== projectDir) {
if (await hasLockfileAsync(workspaceRoot)) {
return;
}
}
const projectLockfile = path.join(projectDir, filename);
if (await fs.pathExists(projectLockfile)) {
return projectLockfile;
}
return null;
}
async function readLockfileDataAsync(
projectDir: string,
lockfilePath: string,
manager: string
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
const packageJsonPath = path.join(projectDir, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return null;
}
const packageJson = await fs.readJson(packageJsonPath);
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
if (!lockfileData) {
return null;
}
return { lockfileData, packageJson };
}
/**
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
* problems before uploading to the builder.
*/
export async function checkLockfileAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
return;
}
const { projectDir, requiredPackageManager, vcsClient } = ctx;
// --- 1. Lockfile existence ---
if (!requiredPackageManager) {
Log.error('No lockfile found in the project directory.');
Log.error(
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
);
Log.error(
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
if (!lockInfo) {
// Unknown package manager — nothing we can validate
return;
}
// For bun, also check the text lockfile variant
const lockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
: await findLockfileAsync(projectDir, lockInfo.filename);
if (!lockfilePath) {
Log.error(
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
);
Log.error(
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 2. Lockfile tracked in VCS ---
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
Log.error(
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
);
Log.error(
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 3. Conflicting lockfiles ---
const foundManagers = new Set<string>();
for (const { filename, manager } of ALL_LOCKFILES) {
if (await findLockfileAsync(projectDir, filename)) {
foundManagers.add(manager);
}
}
if (foundManagers.size > 1) {
const managerList = [...foundManagers].sort().join(', ');
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
Log.error(
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
const parsableLockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
: lockfilePath;
const parsed = await readLockfileDataAsync(
projectDir,
parsableLockfilePath,
requiredPackageManager
throw new Error(
`No lockfile found in the project directory.\n` +
`A lockfile is required to ensure deterministic dependency installation in EAS.\n` +
`Run your package manager's install command (e.g. "npm install") to generate one.\n` +
`To skip this check, run this command with EAS_BUILD_SKIP_LOCKFILE_CHECK=1.`
);
if (!parsed) {
return; // Can't parse — skip remaining checks
}
// --- 4. Lockfile out of sync ---
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
if (mismatches.length > 0) {
Log.error('Lockfile is out of sync with package.json:');
for (const m of mismatches) {
Log.error(
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
);
}
Log.error(
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 5. Peer dependency conflicts ---
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
if (peerConflicts.length > 0) {
Log.error('Peer dependency conflicts detected:');
for (const c of peerConflicts) {
Log.error(
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
);
}
Log.error(
'Update the conflicting packages to compatible versions, then run ' +
`"${requiredPackageManager} install" and commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
}
@@ -0,0 +1,137 @@
import dateFormat from 'dateformat';
import {
confirmRecentConvexInviteAsync,
formatConvexProject,
formatConvexTeam,
formatConvexTeamConnection,
getConvexProjectDashboardUrl,
getConvexTeamDashboardUrl,
} from '../convex';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../graphql/types/ConvexTeamConnection';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
jest.mock('../../log');
jest.mock('../../prompts');
describe('Convex command utilities', () => {
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
hasBeenClaimed: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('builds encoded Convex dashboard URLs', () => {
expect(getConvexTeamDashboardUrl(mockConnection)).toBe(
'https://dashboard.convex.dev/t/test%20team'
);
expect(getConvexProjectDashboardUrl(mockProject)).toBe(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('formats team and project metadata with invite details', () => {
expect(formatConvexTeam(mockConnection)).toBe('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
expect(formatConvexTeamConnection(mockConnection)).toContain('Claimed');
expect(formatConvexTeamConnection(mockConnection)).toContain('Yes');
expect(formatConvexTeamConnection(mockConnection)).toContain('[email protected]');
expect(formatConvexTeamConnection(mockConnection)).toContain(
dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss')
);
expect(formatConvexProject(mockProject)).toContain('project-123');
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
expect(formatConvexProject(mockProject)).not.toContain('team-123');
});
it('does not prompt when the previous invite timestamp is invalid', async () => {
await expect(
confirmRecentConvexInviteAsync(
{ ...mockConnection, invitedAt: 'not-a-date' },
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt when the previous invite is old', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts before resending a recent invite in interactive mode', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const recentInviteTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: recentInviteTimestamp,
},
{ nonInteractive: false }
)
).resolves.toBe(false);
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining(dateFormat(recentInviteTimestamp, 'mmm dd HH:MM:ss')),
});
});
it('warns and proceeds for recent invites in non-interactive mode', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: true }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
});
});
@@ -0,0 +1,20 @@
export function preprocessBuildCommandArgs(argv: string[]): string[] {
return withOptionalSimulatorValue(argv);
}
// oclif does not support string flags with optional values. We want "--simulator"
// to prompt for a simulator, while still supporting "--simulator <name-or-udid>".
// Insert an empty string value for the bare flag before oclif parses argv.
function withOptionalSimulatorValue(argv: string[]): string[] {
const simulatorFlagIndex = argv.indexOf('--simulator');
const simulatorValue = argv[simulatorFlagIndex + 1];
if (
simulatorFlagIndex === -1 ||
(simulatorValue !== undefined && !simulatorValue.startsWith('-'))
) {
return argv;
}
return [...argv.slice(0, simulatorFlagIndex + 1), '', ...argv.slice(simulatorFlagIndex + 1)];
}
@@ -74,7 +74,10 @@ describe(getProjectIdAsync, () => {
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
jest.mocked(isExpoInstalled).mockReturnValue(true);
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
});
it('gets the project ID from app config if exists', async () => {
@@ -0,0 +1,89 @@
import chalk from 'chalk';
import dateFormat from 'dateformat';
import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
import Log, { link } from '../log';
import { confirmAsync } from '../prompts';
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;
export function formatConvexInviteTimestamp(timestamp: string): string {
return dateFormat(timestamp, 'mmm dd HH:MM:ss');
}
export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
}
export function getConvexProjectDashboardUrl(project: ConvexProjectData): string {
return `${getConvexTeamDashboardUrl(project.convexTeamConnection)}/${encodeURIComponent(
project.convexProjectSlug
)}`;
}
export function formatConvexTeam(connection: ConvexTeamConnectionData): string {
return `${connection.convexTeamName} / ${connection.convexTeamSlug}`;
}
export function formatConvexTeamConnection(connection: ConvexTeamConnectionData): string {
const lines = [
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`,
];
if (connection.invitedEmail) {
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
lines.push(`${chalk.bold('Invited at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
}
return lines.join('\n');
}
export function formatConvexProject(project: ConvexProjectData): string {
return [
`${chalk.bold('Name')}: ${project.convexProjectName}`,
`${chalk.bold('Slug')}: ${project.convexProjectSlug}`,
`${chalk.bold('Identifier')}: ${project.convexProjectIdentifier}`,
`${chalk.bold('Team')}: ${formatConvexTeam(project.convexTeamConnection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoConvexTeams(accountName: string): void {
Log.warn(`No Convex team is linked to account ${chalk.bold(accountName)} on EAS.`);
}
export function logNoConvexProject(projectName: string): void {
Log.warn(`No Convex project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export async function confirmRecentConvexInviteAsync(
connection: ConvexTeamConnectionData,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<boolean> {
const invitedAt = connection.invitedAt ? new Date(connection.invitedAt) : null;
if (!invitedAt || Number.isNaN(invitedAt.getTime())) {
return true;
}
const timeSinceInviteMs = Date.now() - invitedAt.getTime();
if (timeSinceInviteMs >= RECENT_INVITE_THRESHOLD_MS) {
return true;
}
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${formatConvexInviteTimestamp(connection.invitedAt)}.`;
if (nonInteractive) {
Log.warn(
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
);
return true;
}
return await confirmAsync({
message: `${previousInvite} Are you sure you want to send another invite?`,
});
}
@@ -1,12 +1,14 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { expoCommandAsync } from '../../../utils/expoCli';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../utils/expoCli');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
@@ -79,21 +81,11 @@ describe('commands', () => {
packageManager: 'npm',
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
['install', 'expo-updates', '@expo/metro-runtime'],
{ silent: true }
);
});
});
@@ -7,6 +7,7 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { expoCommandAsync } from '../../utils/expoCli';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
@@ -48,16 +49,8 @@ export async function installProjectDependenciesAsync(
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`;
await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true });
spinner.succeed(`Installed project dependencies`);
}
@@ -13,14 +13,15 @@ export default class AccountLogin extends EasCommand {
static override flags = {
// can pass either --sso or -s
sso: Flags.boolean({
description: 'Login with SSO',
description: 'Log in with SSO',
char: 's',
default: false,
}),
browser: Flags.boolean({
description: 'Login with your browser',
description: 'Log in with your browser (default; use --no-browser for CLI-based login)',
char: 'b',
default: false,
default: true,
allowNo: true,
}),
};
+13 -2
View File
@@ -12,6 +12,7 @@ import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEn
import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createFingerprintAsync } from '../../fingerprint/cli';
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
@@ -51,6 +52,11 @@ export default class BuildDev extends EasCommand {
description: 'Install and run the development build without starting the bundler server.',
default: false,
}),
simulator: Flags.string({
description:
'iOS simulator name or UDID to install and run the development build on. If no value is provided, you will be prompted to select a simulator.',
multiple: false,
}),
};
static override contextDefinition = {
@@ -63,7 +69,7 @@ export default class BuildDev extends EasCommand {
};
protected override async runAsync(): Promise<any> {
const { flags } = await this.parse(BuildDev);
const { flags } = await this.parse(BuildDev, preprocessBuildCommandArgs(this.argv));
const {
loggedIn: { actor, graphqlClient },
@@ -81,6 +87,10 @@ export default class BuildDev extends EasCommand {
if (process.platform !== 'darwin' && platform === Platform.IOS) {
Errors.error('Running iOS builds in simulator is only supported on macOS.', { exit: 1 });
}
if (flags.simulator && platform !== Platform.IOS) {
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
}
const simulator = flags.simulator === '' ? true : flags.simulator;
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
@@ -132,7 +142,7 @@ export default class BuildDev extends EasCommand {
);
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build);
await downloadAndRunAsync(build, simulator);
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
@@ -182,6 +192,7 @@ export default class BuildDev extends EasCommand {
autoSubmit: false,
localBuildOptions: {},
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
simulator,
},
actor,
getDynamicPrivateProjectConfigAsync,
+16 -3
View File
@@ -4,6 +4,7 @@ import { pathExists } from 'fs-extra';
import { getLatestBuildAsync, listAndSelectBuildOnAppAsync } from '../../build/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasPaginatedQueryFlags,
@@ -17,6 +18,7 @@ import { appPlatformDisplayNames } from '../../platform';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { RunArchiveFlags, getEasBuildRunCachedAppPath, runAsync } from '../../run/run';
import { SimulatorRunTarget } from '../../run/ios/run';
import { isRunnableOnSimulatorOrEmulator } from '../../run/utils';
import {
downloadAndMaybeExtractAppAsync,
@@ -32,6 +34,7 @@ interface RawRunFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: string;
}
interface RunCommandFlags {
@@ -40,6 +43,7 @@ interface RunCommandFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: SimulatorRunTarget;
}
export default class Run extends EasCommand {
@@ -72,6 +76,11 @@ export default class Run extends EasCommand {
'Name of the build profile used to create the build to run. When specified, only builds created with the specified build profile will be queried.',
helpValue: 'PROFILE_NAME',
}),
simulator: Flags.string({
description:
'iOS simulator name or UDID to install and run the build on. If no value is provided, you will be prompted to select a simulator.',
multiple: false,
}),
...EasPaginatedQueryFlags,
};
@@ -82,7 +91,7 @@ export default class Run extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Run);
const { flags: rawFlags } = await this.parse(Run, preprocessBuildCommandArgs(this.argv));
const flags = await this.sanitizeFlagsAsync(rawFlags);
const queryOptions = getPaginatedQueryOptions(flags);
const {
@@ -99,11 +108,11 @@ export default class Run extends EasCommand {
queryOptions
);
await runAsync(simulatorBuildPath, flags.selectedPlatform);
await runAsync(simulatorBuildPath, flags.selectedPlatform, flags.simulator);
}
private async sanitizeFlagsAsync(flags: RawRunFlags): Promise<RunCommandFlags> {
const { platform, limit, offset, profile, ...runArchiveFlags } = flags;
const { platform, limit, offset, profile, simulator, ...runArchiveFlags } = flags;
const selectedPlatform = await resolvePlatformAsync(platform);
@@ -112,6 +121,9 @@ export default class Run extends EasCommand {
exit: 1,
});
}
if (simulator && selectedPlatform !== AppPlatform.Ios) {
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
}
if (
runArchiveFlags.path &&
@@ -137,6 +149,7 @@ export default class Run extends EasCommand {
limit,
offset,
profile,
simulator: simulator === '' ? true : simulator,
};
}
}
+126 -218
View File
@@ -1,5 +1,5 @@
import { ExpoConfig } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -8,7 +8,6 @@ import * as path from 'path';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContext } from '../credentials/context';
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
@@ -22,31 +21,33 @@ import {
WorkflowProjectSourceType,
WorkflowRunStatus,
} from '../graphql/generated';
import { getWorkflowRunUrl } from '../build/utils/url';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
import { Client } from '../vcs/vcs';
import NoVcsClient from '../vcs/clients/noVcs';
import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
const EXPO_GO_APP_VERSION = '55.0.11';
const EXPO_GO_BUILD_NUMBER = '1017799';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Create or get TestFlight group
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
@@ -70,7 +71,7 @@ async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await new Promise(resolve => setTimeout(resolve, 10000));
await sleepAsync(10_000);
continue;
}
}
@@ -118,30 +119,27 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
return true;
};
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
// patching it would freeze the spinner animation during suppressed async work.
// Suppress stdout and console output during credential setup so its verbose
// log lines don't interleave with our progress spinners.
process.stdout.write = capture as any;
console.log = () => {};
console.error = () => {};
console.warn = () => {};
let didThrow = false;
try {
return await fn();
} catch (error) {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (capturedOutput) {
originalConsoleLog(capturedOutput);
}
didThrow = true;
throw error;
} finally {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (didThrow && capturedOutput) {
originalConsoleLog(capturedOutput);
}
}
}
/* eslint-enable no-console */
@@ -159,6 +157,10 @@ export default class Go extends EasCommand {
description: 'App name',
default: 'My Expo Go',
}),
'sdk-version': Flags.string({
description: 'Expo Go SDK version to prepare (default: latest)',
required: false,
}),
credentials: Flags.boolean({
description: 'Interactively select credentials (default: auto-select)',
default: false,
@@ -187,34 +189,34 @@ export default class Go extends EasCommand {
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const sdkVersion = flags['sdk-version'];
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
await fs.emptyDir(projectDir);
const originalCwd = process.cwd();
process.chdir(projectDir);
const setupSpinner = ora('Creating project...').start();
// Step 1: Create project files and initialize git (silently)
try {
await withSuppressedOutputAsync(async () => {
await this.createProjectFilesAsync(projectDir, bundleId, appName);
await this.initGitRepoAsync(projectDir);
});
const vcsClient = resolveVcsClient();
// Step 2: Create/link EAS project (silently)
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
);
}
const appName = flags.name;
const slug = deriveBundleIdSlug(bundleId);
// Step 3: Set up iOS credentials and create App Store Connect app
const setupSpinner = ora('Setting up project...').start();
let projectId: string;
try {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
);
} catch (error) {
setupSpinner.fail();
throw error;
}
const tmpDir = path.join(os.tmpdir(), `eas-go-${Date.now()}`);
await fs.ensureDir(tmpDir);
const vcsClient = new NoVcsClient({ cwdOverride: tmpDir });
try {
const ascApp = await this.setupCredentialsAsync(
projectDir,
slug,
projectId,
bundleId,
appName,
@@ -229,28 +231,30 @@ export default class Go extends EasCommand {
}
);
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
bundleId,
appName,
ascApp.id,
sdkVersion,
tmpDir,
vcsClient
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Workflow failed');
throw new Error('Build failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Workflow was canceled');
throw new Error('Build was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
} catch (e) {
Log.debug('TestFlight group setup failed:', e);
}
Log.newLine();
@@ -266,112 +270,27 @@ export default class Go extends EasCommand {
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
await fs.remove(projectDir);
} catch (error) {
Log.gray(`Project files preserved for debugging: ${projectDir}`);
throw error;
} finally {
process.chdir(originalCwd);
await fs.remove(tmpDir);
}
}
private generateBundleId(actor: Actor): string {
const username = actor.accounts[0].name;
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
const username = ensureActorHasPrimaryAccount(actor).name;
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
// Deterministic bundle ID per user + SDK version (reuses same ASC app)
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
},
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJson = {
cli: {
version: '>= 5.0.0',
},
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: {},
},
},
};
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: {
expo: '~54.0.0',
},
};
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
await spawnAsync('npm', ['install'], { cwd: projectDir });
}
private async initGitRepoAsync(projectDir: string): Promise<void> {
await spawnAsync('git', ['init'], { cwd: projectDir });
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
return `com.${sanitizedUsername}.expogo`;
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
bundleId: string
slug: string
): Promise<string> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const account = actor.accounts[0];
const account = ensureActorHasPrimaryAccount(actor);
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
@@ -380,38 +299,36 @@ export default class Go extends EasCommand {
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
const projectId = await AppMutation.createAppAsync(graphqlClient, {
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
projectDir: string,
slug: string,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: VcsClient,
vcsClient: Client,
customizeCreds: boolean,
onBeforeAppleAuth?: () => void
): Promise<App> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const exp: ExpoConfig = { name: appName, slug, ios: { bundleIdentifier: bundleId } };
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive: false,
autoAcceptCredentialReuse: !customizeCreds,
projectDir,
projectDir: process.cwd(),
user: actor,
graphqlClient,
analytics,
@@ -425,7 +342,7 @@ export default class Go extends EasCommand {
const targets: Target[] = [
{
targetName: exp.slug,
targetName: slug,
bundleIdentifier: bundleId,
entitlements: {},
},
@@ -437,43 +354,25 @@ export default class Go extends EasCommand {
},
];
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
const easJsonPath = path.join(projectDir, 'eas.json');
const easJson = await fs.readJson(easJsonPath);
easJson.submit = easJson.submit || {};
easJson.submit.production = easJson.submit.production || {};
easJson.submit.production.ios = easJson.submit.production.ios || {};
easJson.submit.production.ios.ascAppId = ascAppResult.id;
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
return ascAppResult;
const ascApp = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
// Set up push notifications (outside suppressed block so prompts are visible)
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
const setupPushKeyAction = new SetUpPushKey(appLookupParams);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
if (!isPushKeySetup) {
if (customizeCreds) {
@@ -492,14 +391,30 @@ export default class Go extends EasCommand {
return ascApp;
}
private async runWorkflowAsync(
private async dispatchWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
vcsClient: VcsClient
bundleId: string,
appName: string,
ascAppId: string,
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
appId: projectId,
ascAppId,
appName,
bundleId,
sdkVersion,
});
await Promise.all(
repackConfig.files.map(f => fs.writeFile(path.join(tmpDir, f.fileName), f.fileContents))
);
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
@@ -508,42 +423,35 @@ export default class Go extends EasCommand {
vcsClient,
accountId: account.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
filePath: path.join(tmpDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
filePath: path.join(tmpDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory: '.',
},
}
);
const result = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, {
appId: projectId,
sdkVersion: repackConfig.sdkVersion,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
},
});
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
return { workflowUrl, workflowRunId };
return { workflowUrl, workflowRunId: result.id };
}
private async monitorWorkflowJobsAsync(
@@ -584,8 +492,8 @@ export default class Go extends EasCommand {
});
failedFetchesCount = 0;
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
const repackJob = workflowRun.jobs.find(j => j.key === 'build');
const submitJob = workflowRun.jobs.find(j => j.key === 'submit');
if (!buildCompleted) {
if (repackJob?.status === WorkflowJobStatus.Success) {
@@ -0,0 +1,471 @@
import spawnAsync from '@expo/spawn-async';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
import {
ConvexTeamConnectionData,
SetupConvexProjectResultData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { Actor } from '../../../../user/User';
import IntegrationsConvexConnect from '../connect';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('@expo/spawn-async');
jest.mock('fs-extra');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testProjectDir = '/test/project';
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor: Actor = {
__typename: 'User',
id: 'test-user-id',
username: testAccountName,
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
users: [],
},
preferences: { onboarding: null },
accounts: [],
};
const mockRobotActor: Actor = {
__typename: 'Robot',
id: 'robot-1',
featureGates: {},
isExpoAdmin: false,
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockSetupResult: SetupConvexProjectResultData = {
convexDeploymentName: 'happy-otter-123',
convexDeploymentUrl: 'https://happy-otter-123.convex.cloud',
deployKey: 'dev:happy-otter-123|abc123token',
convexProject: {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'testapp',
convexProjectSlug: 'testapp',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
},
};
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsConvexConnect {
const command = new IntegrationsConvexConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp' },
projectDir: testProjectDir,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(fs.readFile).mockResolvedValue('' as never);
jest.mocked(spawnAsync).mockResolvedValue({} as never);
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue('aws-us-east-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(promptAsync).mockImplementation(async (params: any) => ({
[params.name]: params.initial,
}));
jest.mocked(ConvexMutation.createConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(ConvexMutation.setupConvexProjectAsync).mockResolvedValue(mockSetupResult);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
id: 'env-var-1',
name: 'EXPO_PUBLIC_CONVEX_URL',
value: mockSetupResult.convexDeploymentUrl,
environments: [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
});
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({
id: 'env-var-1',
name: 'EXPO_PUBLIC_CONVEX_URL',
value: mockSetupResult.convexDeploymentUrl,
environments: [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
});
});
it('creates a Convex team and project with the new mutation shape', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
});
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('npx convex dev'));
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('https://docs.expo.dev/guides/using-convex')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://docs.convex.dev/'));
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('uses an existing Convex team connection', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand(['--region', 'aws-eu-west-1']).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-eu-west-1',
projectName: 'testapp',
});
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('does not create a Convex team if installing the Convex package fails first', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
jest.mocked(spawnAsync).mockRejectedValue(new Error('install failed') as never);
await expect(createCommand([]).runAsync()).rejects.toThrow('install failed');
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).not.toHaveBeenCalled();
});
it('skips the verified email invite when the actor has no email', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([], mockRobotActor).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('logs the error when sending a verified email invite fails', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest
.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync)
.mockRejectedValue(new Error('Convex invite service unavailable'));
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to send Convex team invitation to [email protected]')
);
expect(Log.warn).toHaveBeenCalledWith('Convex invite service unavailable');
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('asks before resending a recent team invite for an existing connection', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
invitedEmail: '[email protected]',
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
});
it('skips sending an invite when the Convex team has already been claimed', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
hasBeenClaimed: true,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).not.toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Convex team has already been claimed. Skipping Convex team invitation.'
);
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
});
it('writes the deploy key and Convex URL to .env.local', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([]).runAsync();
const envPath = `${testProjectDir}/.env.local`;
const writeCall = jest.mocked(fs.writeFile).mock.calls.find(call => call[0] === envPath);
expect(writeCall?.[1]).toContain('CONVEX_DEPLOY_KEY=dev:happy-otter-123|abc123token');
expect(writeCall?.[1]).toContain('EXPO_PUBLIC_CONVEX_URL=https://happy-otter-123.convex.cloud');
});
it('creates the Convex URL as a project EAS environment variable for all default environments', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([]).runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
filterNames: ['EXPO_PUBLIC_CONVEX_URL'],
});
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'EXPO_PUBLIC_CONVEX_URL',
value: 'https://happy-otter-123.convex.cloud',
environments: [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('updates an existing project EAS environment variable for the Convex URL', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'env-var-1',
name: 'EXPO_PUBLIC_CONVEX_URL',
value: 'https://old-deployment.convex.cloud',
environments: [DefaultEnvironment.Production],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining(
'EAS already has an EXPO_PUBLIC_CONVEX_URL environment variable'
),
});
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: 'env-var-1',
name: 'EXPO_PUBLIC_CONVEX_URL',
value: 'https://happy-otter-123.convex.cloud',
environments: [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
});
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
});
it('skips updating an existing project EAS environment variable for the Convex URL when declined', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
{
id: 'env-var-1',
name: 'EXPO_PUBLIC_CONVEX_URL',
value: 'https://old-deployment.convex.cloud',
environments: [DefaultEnvironment.Production],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
]);
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining(
'EAS already has an EXPO_PUBLIC_CONVEX_URL environment variable'
),
});
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipped updating EAS environment variable')
);
});
it('prompts before overwriting existing Convex .env.local values', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('CONVEX_DEPLOY_KEY=old-key\n' as never);
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('already contains CONVEX_DEPLOY_KEY'),
});
});
it('uses defaults in non-interactive mode', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand(['--non-interactive']).runAsync();
expect(selectAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
})
);
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
appId: testProjectId,
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
})
);
});
});
@@ -0,0 +1,126 @@
import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import IntegrationsConvexDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsConvexDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexDashboard {
const command = new IntegrationsConvexDashboard([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
});
it('opens the linked Convex project dashboard', async () => {
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false);
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand().runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('open failed'));
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await expect(createCommand().runAsync()).rejects.toThrow('open failed');
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
});
@@ -0,0 +1,121 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import IntegrationsConvexProjectDelete from '../project/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexProjectDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(argv: string[]): IntegrationsConvexProjectDelete {
const command = new IntegrationsConvexProjectDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(ConvexMutation.deleteConvexProjectAsync).mockResolvedValue('convex-project-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the linked Convex project after confirmation', async () => {
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex project link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import IntegrationsConvexProject from '../project';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
describe(IntegrationsConvexProject, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexProject {
const command = new IntegrationsConvexProject([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
});
it('prints linked Convex project metadata', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex project linked to testapp')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('project-123'));
});
it('prints an empty state when no Convex project is linked', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
});
});
@@ -0,0 +1,188 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamDelete from '../team/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(argv: string[]): IntegrationsConvexTeamDelete {
const command = new IntegrationsConvexTeamDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.deleteConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the selected Convex team link after confirmation', async () => {
await createCommand(['test-team']).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand(['test-team']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex team link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['test-team', '--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
});
it('logs an empty state when no team links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('throws when the provided team is not linked to the account', async () => {
await expect(createCommand(['missing-team']).runAsync()).rejects.toThrow(
'Convex team missing-team is not linked to this account.'
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive', '--yes']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
it('prompts to select a team link when multiple links exist in interactive mode', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand(['--yes']).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to remove',
expect.any(Array)
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
});
@@ -0,0 +1,245 @@
import dateFormat from 'dateformat';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamInvite from '../team/invite';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamInvite, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor = {
__typename: 'User',
id: 'test-user-id',
email: '[email protected]',
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(
argv: string[],
actor: { __typename: string; [key: string]: any } = mockActor
): IntegrationsConvexTeamInvite {
const command = new IntegrationsConvexTeamInvite(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('sends an invite for the linked Convex team', async () => {
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
});
it('prints previous invite metadata when it exists', async () => {
await createCommand([]).runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('connection-1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Previous invite'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('No'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('[email protected]'));
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining(dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss'))
);
});
it('prompts before resending a recent invite', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips resending a recent invite when the user does not confirm', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});
it('skips sending an invite when the Convex team has already been claimed', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
hasBeenClaimed: true,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Convex team has already been claimed. Skipping Convex team invitation.'
);
});
it('resends recent invites in non-interactive mode with a warning', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand(['--non-interactive']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips the invite when the actor has no email', async () => {
await createCommand([], { __typename: 'Robot', id: 'robot-1' }).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('selects a team link when multiple are available', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to invite yourself to',
expect.any(Array)
);
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive']).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
});
@@ -0,0 +1,81 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import IntegrationsConvexTeam from '../team';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../log');
describe(IntegrationsConvexTeam, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
function createCommand(): IntegrationsConvexTeam {
const command = new IntegrationsConvexTeam([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
});
it('prints linked Convex team metadata', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex teams linked to @testuser')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Yes'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('prints an empty state when no Convex teams are linked', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
});
});
@@ -0,0 +1,445 @@
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import { DefaultEnvironment } from '../../../build/utils/environment';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { confirmRecentConvexInviteAsync, formatConvexTeam } from '../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { ConvexMutation } from '../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import { ConvexTeamConnectionData } from '../../../graphql/types/ConvexTeamConnection';
import Log from '../../../log';
import { ora } from '../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { Actor } from '../../../user/User';
const CONVEX_REGIONS = [
{ title: 'US East (aws-us-east-1)', value: 'aws-us-east-1' },
{ title: 'EU West (aws-eu-west-1)', value: 'aws-eu-west-1' },
];
const DEFAULT_REGION = 'aws-us-east-1';
const EAS_CONVEX_ENV_VAR_NAME = 'EXPO_PUBLIC_CONVEX_URL';
const EAS_CONVEX_ENVIRONMENTS = [
DefaultEnvironment.Production,
DefaultEnvironment.Preview,
DefaultEnvironment.Development,
];
type TeamInviteResult = 'sent' | 'skipped' | 'failed';
export default class IntegrationsConvexConnect extends EasCommand {
static override description = 'connect Convex to your Expo project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EASNonInteractiveFlag,
region: Flags.string({
description: 'Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)',
options: CONVEX_REGIONS.map(r => r.value),
}),
'team-name': Flags.string({
description: 'Name for the new Convex team (defaults to EAS account name)',
}),
'project-name': Flags.string({
description: 'Name for the Convex project (defaults to app slug)',
}),
};
async runAsync(): Promise<void> {
const {
flags: {
region: regionFlag,
'team-name': teamNameFlag,
'project-name': projectNameFlag,
'non-interactive': nonInteractive,
},
} = await this.parse(IntegrationsConvexConnect);
const {
privateProjectConfig: { projectId, exp, projectDir },
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(IntegrationsConvexConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
// 1. Check for existing Convex team connections
const existingConnections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
let connection: ConvexTeamConnectionData | null = null;
let teamName: string | null = null;
if (existingConnections.length === 0) {
// 2a. No connection - resolve a team name and create it after local package install succeeds
teamName = await this.resolveTeamNameAsync(teamNameFlag, account.name, nonInteractive);
} else if (existingConnections.length === 1) {
// 2b. Single existing connection
connection = existingConnections[0];
Log.withTick(`Using existing Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} else {
// 2c. Multiple connections - prompt to select
connection = await this.selectConnectionAsync(existingConnections, nonInteractive);
}
// 3. Resolve project name before project setup mutation
const projectName = await this.resolveProjectNameAsync(
projectNameFlag,
exp.slug,
nonInteractive
);
// 4. Install the Convex package before creating new server-side resources
await this.installConvexPackageAsync(projectDir);
if (!connection) {
const spinner = ora('Creating Convex team').start();
try {
connection = await ConvexMutation.createConvexTeamConnectionAsync(graphqlClient, {
accountId: account.id,
deploymentRegion: region,
convexTeamName: teamName ?? account.name,
});
spinner.succeed(`Created Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} catch (error) {
spinner.fail('Failed to create Convex team');
throw error;
}
}
// 5. Set up Convex project
const spinner = ora('Setting up Convex project').start();
let setupResult;
try {
setupResult = await ConvexMutation.setupConvexProjectAsync(graphqlClient, {
appId: projectId,
convexTeamConnectionId: connection.id,
deploymentRegion: region,
projectName,
});
spinner.succeed(
`Created Convex project ${chalk.bold(projectName)} with deployment ${chalk.bold(setupResult.convexDeploymentName)}`
);
} catch (error) {
spinner.fail('Failed to set up Convex project');
throw error;
}
// 6. Save the Convex URL as an EAS environment variable for builds
await this.upsertConvexUrlEasEnvVarAsync(
graphqlClient,
projectId,
setupResult.convexDeploymentUrl,
nonInteractive
);
// 7. Send team invite (non-fatal)
const teamInviteResult = await this.sendTeamInviteAsync(graphqlClient, connection, actor, {
nonInteractive,
});
// 8. Write deploy key and URL to .env.local
await this.writeEnvLocalAsync(
projectDir,
setupResult.deployKey,
setupResult.convexDeploymentUrl,
nonInteractive
);
// 9. Success message
Log.addNewLineIfNone();
Log.log(chalk.green('Convex is ready!'));
Log.newLine();
Log.log('Next steps:');
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
Log.log(
` 2. Learn how to connect to your new Convex database by following our quickstart guide: ${chalk.cyan('https://docs.expo.dev/guides/using-convex')}`
);
Log.log(` 3. Read more about Convex: ${chalk.cyan('https://docs.convex.dev/')}`);
Log.newLine();
if (teamInviteResult === 'sent') {
Log.log(
`Check your email for an invitation to join your Convex team. Accept it for full dashboard access.`
);
}
}
private async upsertConvexUrlEasEnvVarAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
convexUrl: string,
nonInteractive: boolean
): Promise<void> {
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
filterNames: [EAS_CONVEX_ENV_VAR_NAME],
});
const existingProjectVariable = existingVariables.find(
variable => variable.scope === EnvironmentVariableScope.Project
);
if (existingProjectVariable) {
if (!nonInteractive) {
const overwrite = await confirmAsync({
message: `EAS already has an ${EAS_CONVEX_ENV_VAR_NAME} environment variable for this project. Overwrite it?`,
});
if (!overwrite) {
Log.warn(
`Skipped updating EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)}.`
);
return;
}
}
await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingProjectVariable.id,
name: EAS_CONVEX_ENV_VAR_NAME,
value: convexUrl,
environments: EAS_CONVEX_ENVIRONMENTS,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
});
Log.withTick(
`Updated EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)} for builds`
);
return;
}
await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name: EAS_CONVEX_ENV_VAR_NAME,
value: convexUrl,
environments: EAS_CONVEX_ENVIRONMENTS,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
projectId
);
Log.withTick(
`Created EAS environment variable ${chalk.bold(EAS_CONVEX_ENV_VAR_NAME)} for builds`
);
}
private async resolveRegionAsync(
flagValue: string | undefined,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return DEFAULT_REGION;
}
return await selectAsync('Select a Convex deployment region', CONVEX_REGIONS);
}
private async resolveTeamNameAsync(
flagValue: string | undefined,
accountName: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return accountName;
}
const { teamName } = await promptAsync({
type: 'text',
name: 'teamName',
message: 'Convex team name',
initial: accountName,
validate: (value: string) => (value.trim() ? true : 'Team name cannot be empty'),
});
return teamName;
}
private async resolveProjectNameAsync(
flagValue: string | undefined,
slug: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return slug;
}
const { projectName } = await promptAsync({
type: 'text',
name: 'projectName',
message: 'Convex project name',
initial: slug,
validate: (value: string) => (value.trim() ? true : 'Project name cannot be empty'),
});
return projectName;
}
private async selectConnectionAsync(
connections: ConvexTeamConnectionData[],
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (nonInteractive) {
return connections[0];
}
const choices = connections.map(c => ({
title: `${formatConvexTeam(c)} (created ${new Date(c.createdAt).toLocaleDateString()})`,
value: c,
}));
return await selectAsync('Select a Convex team connection', choices);
}
private getActorEmail(actor: Actor): string | null {
return actor.__typename === 'User' ? actor.email : null;
}
private async sendTeamInviteAsync(
graphqlClient: Parameters<typeof ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync>[0],
connection: ConvexTeamConnectionData,
actor: Actor,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<TeamInviteResult> {
if (connection.hasBeenClaimed) {
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
return 'skipped';
}
const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return 'skipped';
}
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return 'skipped';
}
try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
Log.withTick(`Sent Convex team invitation to ${chalk.bold(email)}`);
return 'sent';
} catch (error) {
Log.warn(
`Failed to send Convex team invitation to ${email}. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} to retry.`
);
Log.warn(error instanceof Error ? error.message : String(error));
return 'failed';
}
}
private async installConvexPackageAsync(projectDir: string): Promise<void> {
Log.newLine();
Log.log(`Running ${chalk.bold('npx expo install convex')}`);
Log.newLine();
try {
await spawnAsync('npx', ['expo', 'install', 'convex'], {
cwd: projectDir,
stdio: 'inherit',
});
Log.withTick(`Installed the ${chalk.bold('convex')} npm package`);
} catch (error) {
Log.warn(
`Failed to install the ${chalk.bold('convex')} npm package. Run ${chalk.cyan(
'npx expo install convex'
)} from your project directory, then run ${chalk.cyan('npx convex dev')}.`
);
throw error;
}
}
private async writeEnvLocalAsync(
projectDir: string,
deployKey: string,
convexUrl: string,
nonInteractive: boolean
): Promise<void> {
const envPath = path.join(projectDir, '.env.local');
let existingContent: Record<string, string> = {};
let rawContent = '';
if (await fs.pathExists(envPath)) {
rawContent = await fs.readFile(envPath, 'utf8');
existingContent = dotenv.parse(rawContent);
if (existingContent.CONVEX_DEPLOY_KEY && !nonInteractive) {
const overwrite = await confirmAsync({
message: `.env.local already contains CONVEX_DEPLOY_KEY. Overwrite Convex values?`,
});
if (!overwrite) {
Log.log('Skipping .env.local update. Deploy key:');
Log.log(` CONVEX_DEPLOY_KEY=${deployKey}`);
Log.log(` EXPO_PUBLIC_CONVEX_URL=${convexUrl}`);
return;
}
}
}
const updatedContent = this.mergeEnvContent(rawContent, {
CONVEX_DEPLOY_KEY: deployKey,
EXPO_PUBLIC_CONVEX_URL: convexUrl,
});
await fs.writeFile(envPath, updatedContent);
Log.withTick(`Wrote Convex config to ${chalk.bold('.env.local')}`);
}
private mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
let content = rawContent;
const keysToAdd: Record<string, string> = { ...newVars };
for (const [key, value] of Object.entries(newVars)) {
// Replace existing line if present
const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
delete keysToAdd[key];
}
}
// Append any keys that weren't already in the file
const remaining = Object.entries(keysToAdd);
if (remaining.length > 0) {
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
for (const [key, value] of remaining) {
content += `${key}=${value}\n`;
}
}
return content;
}
}
@@ -0,0 +1,46 @@
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
import { getConvexProjectDashboardUrl, logNoConvexProject } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import { ora } from '../../../ora';
export default class IntegrationsConvexDashboard extends EasCommand {
static override description = 'open the Convex dashboard for the linked Convex project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexDashboard, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
const failedMessage = `Unable to open a web browser. Convex dashboard is available at: ${dashboardUrl}`;
const spinner = ora(`Opening ${dashboardUrl}`).start();
try {
const opened = await openBrowserAsync(dashboardUrl);
if (opened) {
spinner.succeed(`Opened ${dashboardUrl}`);
} else {
spinner.fail(failedMessage);
}
} catch (error) {
spinner.fail(failedMessage);
throw error;
}
}
}
@@ -0,0 +1,34 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { formatConvexProject, logNoConvexProject } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import Log from '../../../log';
export default class IntegrationsConvexProject extends EasCommand {
static override description = 'display the Convex project linked to the current Expo app';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexProject, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
Log.log(chalk.bold(`Convex project linked to ${exp.slug}`));
Log.log(formatConvexProject(convexProject));
}
}
@@ -0,0 +1,87 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
formatConvexProject,
getConvexProjectDashboardUrl,
logNoConvexProject,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { confirmAsync } from '../../../../prompts';
export default class IntegrationsConvexProjectDelete extends EasCommand {
static override description =
'remove the Convex project link for the current Expo app from EAS servers';
static override flags = {
...EASNonInteractiveFlag,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
flags: { 'non-interactive': nonInteractive, yes },
} = await this.parse(IntegrationsConvexProjectDelete);
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexProjectDelete, {
nonInteractive,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
Log.addNewLineIfNone();
Log.log(formatConvexProject(convexProject));
Log.newLine();
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message: `Remove this Convex project link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
dashboardUrl,
{ dim: false }
)}`,
});
if (!confirmed) {
Log.error('Canceled deletion of the Convex project link');
return;
}
} else {
Log.warn(
`Removing the Convex project link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
);
}
const spinner = ora('Removing Convex project link').start();
try {
await ConvexMutation.deleteConvexProjectAsync(graphqlClient, convexProject.id);
spinner.succeed(
`Removed Convex project ${chalk.bold(convexProject.convexProjectName)} from EAS servers`
);
} catch (error) {
spinner.fail('Failed to remove Convex project link');
throw error;
}
}
}
@@ -0,0 +1,45 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { formatConvexTeamConnection, logNoConvexTeams } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import Log from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
export default class IntegrationsConvexTeam extends EasCommand {
static override description =
"display Convex teams linked to the current Expo app's owner account";
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexTeam, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
Log.log(chalk.bold(`Convex teams linked to @${account.name}`));
for (const [index, connection] of connections.entries()) {
if (index > 0) {
Log.newLine();
}
Log.log(formatConvexTeamConnection(connection));
}
}
}
@@ -0,0 +1,138 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
formatConvexTeam,
formatConvexTeamConnection,
getConvexTeamDashboardUrl,
logNoConvexTeams,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
export default class IntegrationsConvexTeamDelete extends EasCommand {
static override description =
"remove a Convex team link from the current Expo app owner account's EAS servers";
static override args = {
CONVEX_TEAM: Args.string({
required: false,
description: 'Slug of the Convex team to remove',
}),
};
static override flags = {
...EASNonInteractiveFlag,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
args: { CONVEX_TEAM: team },
flags: { 'non-interactive': nonInteractive, yes },
} = await this.parse(IntegrationsConvexTeamDelete);
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexTeamDelete, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
Log.addNewLineIfNone();
Log.log(formatConvexTeamConnection(connection));
Log.newLine();
const dashboardUrl = getConvexTeamDashboardUrl(connection);
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message: `Remove this Convex team link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
dashboardUrl,
{ dim: false }
)}`,
});
if (!confirmed) {
Log.error('Canceled deletion of the Convex team link');
return;
}
} else {
Log.warn(
`Removing the Convex team link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
);
}
const spinner = ora('Removing Convex team link').start();
try {
await ConvexMutation.deleteConvexTeamConnectionAsync(graphqlClient, connection.id);
spinner.succeed(
`Removed Convex team ${chalk.bold(formatConvexTeam(connection))} from EAS servers`
);
} catch (error) {
spinner.fail('Failed to remove Convex team link');
throw error;
}
}
private async resolveConnectionAsync(
connections: ConvexTeamConnectionData[],
team: string | undefined,
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (team) {
const connection = connections.find(
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
);
if (!connection) {
throw new Error(`Convex team ${team} is not linked to this account.`);
}
return connection;
}
if (connections.length === 1) {
return connections[0];
}
if (nonInteractive) {
throw new Error(
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
);
}
return await selectAsync(
'Select a Convex team link to remove',
connections.map(connection => ({
title: formatConvexTeam(connection),
value: connection,
}))
);
}
}
@@ -0,0 +1,166 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
confirmRecentConvexInviteAsync,
formatConvexInviteTimestamp,
formatConvexTeam,
getConvexTeamDashboardUrl,
logNoConvexTeams,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { selectAsync } from '../../../../prompts';
export default class IntegrationsConvexTeamInvite extends EasCommand {
static override description = 'send a Convex team invitation to your verified email address';
static override args = {
CONVEX_TEAM: Args.string({
required: false,
description: 'Slug of the Convex team to invite yourself to',
}),
};
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
args: { CONVEX_TEAM: team },
flags: { 'non-interactive': nonInteractive },
} = await this.parse(IntegrationsConvexTeamInvite);
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(IntegrationsConvexTeamInvite, {
nonInteractive,
withServerSideEnvironment: null,
});
const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return;
}
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
Log.addNewLineIfNone();
this.logTeam(connection);
this.logPreviousInvite(connection);
Log.newLine();
if (connection.hasBeenClaimed) {
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
return;
}
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
}
const spinner = ora(`Sending Convex team invitation to ${email}`).start();
try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
spinner.succeed(
`Sent Convex team invitation to ${chalk.bold(email)} for ${chalk.bold(formatConvexTeam(connection))}`
);
Log.log(`Convex dashboard: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`);
} catch (error) {
spinner.fail('Failed to send Convex team invitation');
throw error;
}
}
private getActorEmail(actor: { __typename: string; [key: string]: any }): string | null {
return 'email' in actor && typeof actor.email === 'string' ? actor.email : null;
}
private logTeam(connection: ConvexTeamConnectionData): void {
Log.log(chalk.bold(`Convex team ${formatConvexTeam(connection)}`));
Log.log(
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`
);
Log.log(`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`);
}
private logPreviousInvite(connection: ConvexTeamConnectionData): void {
if (!connection.invitedEmail && !connection.invitedAt) {
return;
}
Log.newLine();
Log.log(chalk.bold('Previous invite'));
if (connection.invitedEmail) {
Log.log(`${chalk.bold('Email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
Log.log(`${chalk.bold('Sent at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
}
}
private async resolveConnectionAsync(
connections: ConvexTeamConnectionData[],
team: string | undefined,
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (team) {
const connection = connections.find(
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
);
if (!connection) {
throw new Error(`Convex team ${team} is not linked to this account.`);
}
return connection;
}
if (connections.length === 1) {
return connections[0];
}
if (nonInteractive) {
throw new Error(
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
);
}
return await selectAsync(
'Select a Convex team link to invite yourself to',
connections.map(connection => ({
title: formatConvexTeam(connection),
value: connection,
}))
);
}
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more