Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela f92dd5e9db refactor: rely on inferred rollout relationship flags 2026-03-06 11:43:14 +01:00
Stanisław Chmiela f209951a62 refactor: const-assert oclif option arrays 2026-03-06 11:29:27 +01:00
Stanisław Chmiela 7b17e154c5 test: update oclif v4 command test harness 2026-03-06 10:29:39 +01:00
Stanisław Chmiela e1ba12746a refactor: migrate commands to native oclif v4 args 2026-03-06 10:29:26 +01:00
Stanisław Chmiela b53b709fcc chore: upgrade oclif dependencies to v4 2026-03-06 10:13:49 +01:00
Stanisław Chmiela 146656c7f7 [build-tools] Handle ASC 90478 as closed version train (#3460)
## Why
`submit_ios_from_linux` UNKNOWN_ERROR analysis showed App Store Connect responses that clearly indicate the same version-train failure class but with mixed codes (`90062`, `90186`, `90478`).

Current handling recognizes only `90062` and `90186`, so runs that include `90478` fall back to `UNKNOWN_ERROR`.

## How
- extend `isClosedVersionTrainError` to include ASC code `90478` while keeping strict `.every` matching
- broaden `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` user-facing message to cover both closed train and lower-than-approved version cases
- update unit test to include `90478` in positive closed-train detection

## Examples
Existing `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` runs:
- https://expo.dev/uuid/019cbe83-ac3b-756f-b7ef-447bae5ef4dc
- https://expo.dev/uuid/019cbe66-4f9b-7d95-a35e-06886b646434

Would-be closed-train runs currently persisted as `UNKNOWN_ERROR` (log errors include `90478` with `90062`/`90186`):
- https://expo.dev/uuid/019cbe71-62fa-7fee-9e77-bdc051cc6e8b
- https://expo.dev/uuid/019cbe44-5c75-7211-af66-35fe96a86903

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-05 16:29:48 +01:00
Phil Pluckthun e37411040f [build-tools] Add --include=dev for npm install and --production false for Yarn classic (#3459)
* Add missing `--include=dev` for regular npm install

* Override NODE_ENV globally for yarn

* Add `--production false` for Yarn Classic and remove NODE_ENV change

* Add CHANGELOG entry
2026-03-05 14:39:03 +00:00
Mustafa Abban b71226d90f [worker] Handle non-OK HTTP responses before JSON parsing in upload session (#3457) 2026-03-04 15:35:09 -06:00
Stanisław Chmiela 9a9fe11f73 [build-tools] Handle ASC missing purpose strings as user-facing error (#3456)
## Why
Uploads to App Store Connect can fail with code `90683` when required `*UsageDescription` privacy keys are missing in `Info.plist`.

Without dedicated handling this ends up as a generic upload failure, even though ASC already returns structured details that can guide the user.

## How
- add ASC error recognition for `90683` in `uploadToAsc` failed-state handling
- throw a specific `UserFacingError` with code `EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING`
- parse ASC error descriptions to extract and list missing `*UsageDescription` keys
- provide concrete remediation for both CNG (`ios.infoPlist`) and non-CNG (`Info.plist`) workflows
- set `docsUrl` on the user-facing error to Expo permissions docs
- add unit tests for detection and missing-key parsing helpers

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-04 21:27:59 +01:00
Stanisław Chmiela 1b62b6feae [build-tools] Add ASC invalid bundle ID user-facing error (#3455)
## Why
Some App Store Connect uploads currently end up as generic failures even when ASC returns explicit bundle identifier mismatch validation ([`90054`](https://expo.dev/accounts/dobrokruh/projects/dobrokruh/job-runs/019cb865-ef97-71d1-9137-f6ae4c05d94d)/[`90055`](https://expo.dev/accounts/central-studio/projects/hfc-fuels/job-runs/019cb882-1550-7801-9b4b-9647e1bce364)). We want a user-facing error that explains the mismatch and shows both identifiers so users can quickly determine whether they selected the wrong app or the wrong build profile/build.

## How
- add `isInvalidBundleIdentifierError` detector in `uploadToAsc` for ASC error codes `90054`/`90055`
- when matched, throw `UserFacingError` with code `EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID`
- include both identifiers in message:
  - IPA bundle identifier (best-effort read via `readIpaInfoAsync`)
  - selected ASC app bundle identifier
- provide remediation covering both paths:
  - wrong selected app identifier in submit profile
  - wrong build selected / rebuild with different profile
- use `asyncResult` for best-effort IPA introspection (no nested catch needed)
- add unit tests for invalid bundle-id detector

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
2026-03-04 13:58:10 +01:00
Stanisław Chmiela ebb215db1a [build-tools] Remove -z from nc (#3454)
<!-- 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

Older (default) Android system image package does not support `-z`.

```
  Forward stdin/stdout to a file or network connection.

  -4	Force IPv4
  -6	Force IPv6
  -L	Listen for multiple incoming connections (server mode)
  -U	Use a UNIX domain socket
  -W	SECONDS timeout for more data on an idle connection
  -f	Use FILENAME (ala /dev/ttyS0) instead of network
  -l	Listen for one incoming connection
  -p	Local port number
  -q	Quit SECONDS after EOF on stdin, even if stdout hasn't closed yet
  -s	Local source address
  -t	Allocate tty (must come before -l or -L)
  -u	Use UDP
  -w	SECONDS timeout to establish connection
```

# How

Replaced `-z` with a closed stdin.

# Test Plan

System tests should work.

Deployed worker from this branch at https://github.com/expo/eas-cli/actions/runs/22666833774/job/65700594158.

Confirmed works at https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cb88e-f710-74d3-87ba-3c17bfe45c00.
2026-03-04 12:31:52 +01:00
Stanisław Chmiela cdf49377cd [build-tools] Replace ping with nc for Android network readiness check (#3453)
<!-- 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

`ping` is blocked on Linux VMs. https://expo.dev/accounts/layerztec/projects/layerzwallet/workflows/019cb835-cf4a-7b9f-af32-db474797eb85

# How

Replaced `ping` check with `nc`.

# Test Plan

Tested manually on staging that it works. https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb862-4ea6-7966-9148-5cb8dbfb1724
2026-03-04 10:50:17 +00:00
Stanisław Chmiela 8fb84e86bb [build-tools] Harden Android emulator startup readiness and retries (#3450)
# Why

Android emulators started for workflow/Maestro runs can report boot completion while still lacking functional networking. This makes Maestro tests more flaky.

# How

- Extend `AndroidEmulatorUtils.waitForReadyAsync` to require both:
  - `sys.boot_completed == 1`
  - successful guest-side network probe (`ping 1.1.1.1`)
- Add reusable `AndroidEmulatorUtils.stopAsync` and compose `deleteAsync` from stop+delete logic.
- Add startup retries with escalating readiness timeouts (60s, 120s, 180s) in:
  - `eas/start_android_emulator` base emulator startup
  - `eas/start_android_emulator` clone startup path
  - Android startup path inside `eas/__maestro_test` (`withCleanDeviceAsync`)
- Keep retries at 3 total attempts (initial + 2 retries).

# Test Plan

Tested with integration tests.
2026-03-04 10:18:43 +01:00
Stanisław ChmielaandCopilot d660e3602d [worker] Preserve UserFacingError instead of UNKNOWN_ERROR (#3445)
## Why
Generic/custom workflow steps can throw `UserFacingError`, but worker-level error handling currently maps any non-`BuildError` to `UnknownBuildError` / `UnknownCustomBuildError`. That causes actionable step errors to be persisted and exposed as `UNKNOWN_ERROR`.

## How
- add a `toBuildError` conversion in worker service catch path
- preserve existing `BuildError` values
- convert `UserFacingError` into `BuildError` with the same code/message/docsUrl
- keep unknown fallbacks only for truly unstructured errors
- add integration coverage in `stateSync` to assert generic job `UserFacingError` is exposed in `externalBuildError` and `internalErrorCode`

We should make a bigger refactor to error handling, but it's more involved — https://github.com/expo/eas-cli/pull/3447.

## Test Plan

CI should pass.


```
 PASS  src/__integration__/stateSync.test.ts
  State sync mechanism
    query new worker
      ✓ should return 'new' state (210 ms)
    query worker after dispatch
      ✓ should return 'in-progress' state (170 ms)
    query worker after build
      ✓ should return 'success' state (2062 ms)
    query worker after error
      ✓ should return 'error' state with artifacts from error (88 ms)
      ✓ should expose user-facing error from generic job (60 ms)
```

Co-authored-by: Copilot <[email protected]>
2026-03-04 09:38:36 +01:00
Expo CI 5b9d47c02b update CHANGELOG.md 2026-03-04 08:36:00 +00:00
Expo CI 61b601de88 v18.1.0 2026-03-04 08:33:39 +00:00
Byron KarlenandClaude Sonnet 4.6 c5867c8c07 [eas-cli] Fix login spinner interfering with prompts in eas go command (#3451)
https://github.com/user-attachments/assets/37835e4d-fca0-48a3-87e7-8c26e0c80a32



## Summary

- When running `eas go` while not logged in, an `ora` spinner ("Logging in to Expo...") was started before `getContextAsync` was called
- `getContextAsync` triggers interactive login prompts when unauthenticated, but the active spinner animation conflicts with readline input display, causing garbled/invisible prompts
- Fix: remove the spinner before `getContextAsync` and replace with `Log.withTick(...)` after it completes

## Test plan

- [ ] Run `eas go` while not logged in and verify login prompts are visible and interactive
- [ ] Run `eas go` while already logged in and verify "Logged in as ..." tick appears as expected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-04 09:25:12 +01:00
Stanisław Chmiela f2d5c210a7 [eas-cli] Enable merge when parsing workflow YAML (#3435)
## Summary

https://exponent-internal.slack.com/archives/C02VC9BLMQW/p1772224345536829?thread_ts=1763747642.435979&cid=C02VC9BLMQW

I think it'll be nice to add support for aliases and YAML merge. We already "support" it if the user specifies YAML 1.1 preamble.

## Test plan

Added unit tests. CI should pass. Ran `workflow:run shell.yml` with https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb7ed-5765-720e-8c2d-ee047af84933.
2026-03-04 09:19:12 +01:00
Stanisław Chmiela 6e70f7ee4b [build-tools] Improve ASC build upload error handling and diagnostics (#3444)
## Why
App Store Connect build upload failures were often surfaced as generic errors, which made remediation unclear for common cases like duplicate build numbers. We also needed stricter and safer response parsing so JSON parsing failures don’t leak raw parse errors.

## How
- Added typed `AscApiRequestError` in `AscApiClient` for structured ASC error payloads only.
- Kept non-structured ASC failures as regular `Error`.
- Hardened response parsing in `AscApiClient`:
  - parse response text with `asyncResult` + `JSON.parse`,
  - convert invalid success JSON into a controlled `Malformed JSON response...` error.
- Added `AscApiUtils.createBuildUploadAsync` and switched `uploadToAsc` to call it.
- Added duplicate build-number recognition for `/v1/buildUploads` failures when all ASC error codes are `ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE`.
- Added specific `UserFacingError` (`EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE`) with concrete remediation:
  - increment `ios.buildNumber` (or set `"autoIncrement": true`),
  - rebuild and resubmit,
  - include docs URL.
- Inlined closed-train constants in `uploadToAsc` as requested.

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/utils/ios/__tests__/AscApiClient.test.ts`
- `yarn --cwd packages/build-tools jest-unit src/steps/utils/ios/__tests__/AscApiUtils.test.ts`
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`

All commands above pass on this branch.
2026-03-03 14:18:34 +01:00
Douglas Lowder 1d36f4f636 [cli] Remove environment requirement for update:configure (#3440)
# Why

This adjusts the change in #3418 that added a requirement for the `--environment` flag in `eas update` for SDK 55 and greater.

The requirement should not have applied to the `update:configure` subcommand. This PR removes the requirement there.

# How

Remove the check in `configure.ts`, and adjust flag descriptions.

# Test Plan

Existing tests should pass.
2026-02-28 11:36:58 -08:00
Expo CI f8deba78a5 update CHANGELOG.md 2026-02-27 22:36:57 +00:00
Expo CI df8ebc8f84 v18.0.6 2026-02-27 22:34:25 +00:00
Tomek Czajecki 62ab329f39 [eas-cli] Fix invisible credential prompts in eas go command (#3429) 2026-02-25 19:22:47 +02:00
Ash Wu 198b796804 [build-tools] Wrap parseMaestroResults in try-catch to prevent step failure (#3433)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 21:32:18 +08:00
Tomek Czajecki 110055d943 Add time estimates to go command spinners (#3428)
* [eas-cli] Add time estimates to go command spinners

* Fix formatting
2026-02-25 12:27:36 +02:00
Tomek Czajecki 06e2ba5bdb Update eas go fyi link (#3430) 2026-02-25 12:27:11 +02:00
Ash Wu 15690e7f6b [build-tools] Add eas/report_maestro_test_results build function (#3388)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 17:31:18 +08:00
Expo CI 134e74f50b update CHANGELOG.md 2026-02-25 00:42:47 +00:00
Expo CI 4b3dcda99d v18.0.5 2026-02-25 00:40:34 +00:00
Douglas Lowder 0c32aa3a39 [release] Work around the removal of yarn --silent (#3424)
# Why

When yarn moved to version 4, they removed the `--silent` flag. Because of this change, any use of yarn to call scripts and get output will also get yarn debugging garbage, and there is no way to suppress it.

The release scripts are mostly ok with this, except for calling the `next-version` script. The output of this script has to be clean for it to be used as input for `lerna version`.

# How

Replaced the yarn call with a direct invocation of the node Typescript source, bypassing yarn.

# Test Plan

Since the `trigger-release.yml` workflow supports a dry run mode, it's safe to modify it to check out a test branch and run the workflow many times to debug the issue.

Once this change is merged, a dry run invocation of the workflow should succeed even if no version string is passed in.
2026-02-24 03:37:51 -08:00
Tomek Czajecki 59ac8ae77a [eas-cli] Add go command (#3376) 2026-02-23 15:23:22 -08:00
Douglas Lowder 3e13df470e [eas-cli] Require --environment flag for eas update when SDK >= 55 (#3418)
# Why

Problems can occur if an update intended for a development build is published with the environment defaulting to or set to production, and vice versa.

To prevent such problems in the future, this PR makes the `--environment` flag a required flag for projects using SDK 55 and greater.

# How

Add a new utility function to throw an error if sdkVersion does not meet the requirement, and use that function in `eas update:configure` and `eas update`.

Skip the check if EAS_BUILD env is set.

# Test Plan

Unit tests added.

Testing locally with different projects.
2026-02-23 13:48:35 -08:00
Expo CI 83877f6285 update CHANGELOG.md 2026-02-23 20:47:53 +00:00
Expo CI 56018338e7 v18.0.4 2026-02-23 20:45:25 +00:00
Byron Karlen 0fdf2dd44a Revert "Use auth code flow (#3398)" (#3422)
This reverts commit 1604b7bc20.
2026-02-23 12:30:47 -08:00
Expo CI 0dcd6d465a update CHANGELOG.md 2026-02-20 08:02:18 +00:00
Expo CI d5da4f4167 v18.0.3 2026-02-20 07:59:25 +00:00
Stanisław Chmiela d0b9281bb2 [chore] Fix Yarn v4 prepack commands (#3417)
## Why
Release publish failed in `Publish to npm` for `v18.0.2`.

- `packages/eas-cli/scripts/prepack.js` invoked `yarn oclif manifest`, which fails on Yarn v4 (`Couldn't find a script named \"oclif\"`).
- `packages/create-eas-build-function` `clean` script used an unquoted `*.tsbuildinfo` glob, which fails when no files match.

## How
- Update `packages/eas-cli/scripts/prepack.js` to run `yarn run -T oclif manifest`.
- Quote the tsbuildinfo glob in `packages/create-eas-build-function/package.json`:
  - `rimraf ./build/ "*.tsbuildinfo"`

## Test plan
- `cd packages/eas-cli && mise exec [email protected].4 -- node ./scripts/prepack.js`
- `cd packages/create-eas-build-function && mise exec [email protected].4 -- yarn run clean`
- CI
2026-02-20 08:48:21 +01:00
Expo CI 77e04a3bad v18.0.2 2026-02-19 12:34:59 +00:00
Stanisław Chmiela 92ff6a0da5 [eas-build-job] Add hooks schema (#3408)
## Summary
- introduce a shared `Hooks` type/schema with reusable Zod/Joi validation so any string key maps to step arrays
- wire the new hooks definition through the generic, Android, and iOS job schemas and exports
- expand existing tests to cover accepting arbitrary hook names, empty hook arrays, and invalid hook step payloads

Not sure if it makes sense to make this "smart". I think let's just roll this out manually? Like, have `eas/install_node_modules` steps be sort of manually wrapped with hooks? Or maybe we'll sprinkle them when parsing the steps. I'll see. What I think is right is _not_ to limit the keys to only a few known values.

## Test plan

CI should pass.
2026-02-19 12:07:07 +01:00
Stanisław Chmiela 25d945b63f [worker] Move Yarn version from Mise to Corepack (#3412)
## Why

In https://github.com/expo/eas-cli/pull/3406 EAS workflow building the worker was **not** updated to Yarn v4. This breaks building worker.

I tried adding `defaults.tools.yarn: 4.12.0`, but this unfortunately [is **not** the way to use Yarn v4 on EAS as I've learned](https://docs.expo.dev/more/create-expo/#yarn-2-modern). The right way is using Corepack.

However, previously we explicitly did **not** use Corepack, because `pnpm --version` check the worker does in the beginning of its run was failing because it was run without `cwd`, so it was running within worker package which was configured with Yarn.

Since we want to use Yarn v4, we need to figure out a way to use Corepack.

## How

- Enabled Corepack.
- Set up `yarn` executable like the one we have in `universe`.
- Changed `pnpm --version` to **not** run inside worker (so it does not error).
- Removed `setup-node` for specific Node versions in favor of a Mise override.

## Test plan
- Corepack + Yarn resolution checks (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn --version'` => `4.12.0`
  - same check from `scripts/` => `4.12.0`
- Worker runtime unit test (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn workspace @expo/worker test:unit -- runtimeEnvironment.test.ts'`
- GitHub workflow runs:
  - success on Corepack worker workflow update: https://github.com/expo/eas-cli/actions/runs/22133707462
  - latest run for full set of changes: https://github.com/expo/eas-cli/actions/runs/22133932635
2026-02-19 10:17:57 +01:00
Stanisław Chmiela 37165428d1 [chore] Upgrade repository to Yarn 4 with Mise (#3406)
## Why
- Repository maintenance: standardize on Yarn 4 managed via Mise.
- Prepare for introducing minimum package-age constraints in dependency policy.

## How
- Migrated repo tooling to Yarn 4 with Mise.
- Updated CI and scripts for Yarn 4 behavior:
  - Lerna invoked via `yarn run -T lerna`.
  - Coverage args forwarded correctly (`-- --coverage`).
  - Removed unsupported Yarn 4 `--silent` usage.
- Added missing package-local script dependencies surfaced by Yarn 4 (`jest`, `oxlint`, `rimraf`).
- Increased `EasCommand` coverage test timeout from 30s to 60s. (Was consistently failing in CI.)

## Test Plan
- CI only: `Run tests` matrix (including Node 20 coverage) and other touched workflows.
- Release behavior will be verified during the normal release run.
2026-02-18 09:50:28 +01:00
Stanisław Chmiela 50b9be975e [build-tools] Add IPA info reader build function (#3405)
# Why

We need a build function that can read metadata directly from an IPA so later steps (for example App Store Connect upload flows) can consume canonical bundle values without requiring manual input.

# How

- Added a new build function `eas/read_ipa_info` in `packages/build-tools/src/steps/functions/readIpaInfo.ts`.
- The function accepts `ipa_path` and outputs:
  - `bundle_identifier`
  - `bundle_short_version`
  - `bundle_version`
- IPA inspection reads `Payload/*.app/Info.plist` from the zip and supports both plist formats commonly seen in IPAs:
  - binary plist (`bplist00`) via `bplist-parser`
  - XML plist via `plist`
- Added `UserFacingError` handling for parsing failures/invalid binary plist.
- Added the function to the EAS function registry in `packages/build-tools/src/steps/easFunctions.ts`.
- Added fixture-based tests using `SmallestAppExample.ipa` under `packages/build-tools/src/steps/functions/__tests__/fixtures/`.
- Added `node-stream-zip` and pinned `bplist-parser` to `0.3.2`.

# Test Plan

- Automated tests:
  - `yarn --cwd packages/build-tools test src/steps/functions/__tests__/readIpaInfo.test.ts`
- Typecheck:
  - `yarn --cwd packages/build-tools typecheck`

Coverage in `readIpaInfo.test.ts`:
- reads bundle identifier/short version/version from the fixture IPA
- fails when `Info.plist` is missing
- fails when required plist keys are missing
- verifies build-function outputs are populated correctly
2026-02-17 16:24:45 +01:00
Stanisław Chmiela 2655482f74 [worker] Logs improvements (#3397)
<!-- 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

For the new logs experience we need to identify every log with a unique ID.

Also, in the new logs experience we're going to be handling logs in plaintext, uncompressed, so size starts to matter.

# How

Decorating every log with a unique ID is surprisingly difficult with bunyan. It only runs serializers on the object passed in, I tried adding a `log_id` serializer `serializers: { log_id: () => uuidv7() }` and a non-empty value to build logger, but it was getting serialized in the moment of logger creation.

Instead, I'm adding `log_id` in the `Transform` that also masks secrets. The overall architecture looks like this now:

<img width="1742" height="1126" alt="Bez tytułu-2026-02-04-1436-3" src="https://github.com/user-attachments/assets/956e1b58-75ca-41c5-92ba-8229dd981ff6" />

Also removes `service: 'worker'` and `hostname` from logs. `service` was only being used [here](https://github.com/expo/universe/blob/3246198a3f913bf6fd0fd82e8e3df402cb59e82e/server/website/common/logs-utils.ts#L218) which I don't think is a thing right now.

# Test Plan

Added unit test that ensures `Transform` adds `logId`.

<img width="1257" height="1102" alt="Zrzut ekranu 2026-02-12 o 16 02 32" src="https://github.com/user-attachments/assets/4cfbbf43-744f-4af0-9faf-de0cab54f178" />
2026-02-16 09:31:09 +01:00
Stanisław Chmiela dc103acf06 [build-tools] Increase ASC build processing wait timeout (#3403)
## Summary
- extend the `upload_to_asc` build status polling window from 10 minutes to 30 minutes so slowly processed builds have time to reach TestFlight
- keep the rest of the polling logic and `wait_for_processing` default untouched
2026-02-16 09:02:00 +01:00
Mustafa Abban b87bc5bf8f [worker] unknown error log std error/output (#3402)
* [worker] unknown error log std error/output

* [worker] unknown error log std error/output

* check token
2026-02-13 14:19:04 -05:00
Byron Karlen 1604b7bc20 Use auth code flow (#3398) 2026-02-12 17:24:19 -08:00
Stanisław Chmiela 13a6356fca [chore] Fix post-release lint (#3396)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

After a new release is cut, `lerna.json` and `README.md` want to be reformatted by Oxfmt.

# How

Excluded them from being formatted.

# Test Plan

`yarn fmt` changes no files.
2026-02-12 15:59:27 +01:00
Expo CI 0823a9d675 update CHANGELOG.md 2026-02-12 11:18:30 +00:00
Expo CI 772d4dd654 v18.0.1 2026-02-12 11:14:44 +00:00
Stanisław Chmiela e8da383de4 [chore] Move to Oxlint (#3384)
<!-- 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're moving to Oxc in universe too. The toolset is faster than ESLint / Prettier.

# How

Asked Codex to migrate the repository. I don't think all rules got migrated, unfortunately, but also the lint are in a bit weird state (different rules in `eas-cli` vs the rest of the repo). I hope we can unify the rules soon.

# Test Plan

CI should pass.
2026-02-12 11:44:19 +01:00
Stanisław Chmiela d08f9cfdcb [chore] Remove .tsbuildinfo when deleting dist/build (#3394)
<!-- 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

Release failed because in `prepack` in some packages we delete `dist`, but don't delete `.tsbuildinfo` file.

# How

Made Codex fix all the package scripts. I _think_ we also could skip `yarn build` before publishing.

# Test Plan

Try to release and see if it works.
2026-02-12 11:32:37 +01:00
Expo CI ac6569aa58 v18.0.0 2026-02-11 21:21:31 +00:00
Stanisław Chmiela 960ce7dbf5 [infra] Stop preparing tarballs (#3361)
<!-- 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

Our GitHub released executable Oclif bundles of `eas-cli` are… not too popular. v16.28.0 was downloaded 154k times in the last 7 days from NPM. Since its release (2 months ago) it was downloaded from GitHub 82 times.

<img width="1247" height="491" alt="Zrzut ekranu 2026-01-30 o 15 15 35" src="https://github.com/user-attachments/assets/9c5a904a-e00e-43ee-9875-d1cea15b2108" />

# How

Asked Gemini to remove this. This should speed up release process significantly. This release process has been created 5 years ago (https://github.com/expo/eas-cli/pull/54).

Also removed size comparison workflow. It's not going to make comments like https://github.com/expo/eas-cli/pull/3361#issuecomment-3824036144.

Closes https://github.com/expo/eas-cli/pull/3346.

# Test Plan

CI should pass.
2026-02-11 21:13:47 +00:00
Mustafa Abban 029bbdd007 [worker] - log unknown build error logs in Datadog (#3386)
* log unknown build error logs in Datadog

* import order

* tag build phase, error code

* relay to www

* cleanup, stringify
2026-02-10 16:14:44 -05:00
Byron Karlen 7061a6743a fix changelog (#3389) 2026-02-09 16:24:10 -08:00
281 changed files with 36105 additions and 21047 deletions

No files matched your search

+5
View File
@@ -1,9 +1,14 @@
**/build/**
**/dist/**
**/coverage/**
**/fixtures/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
packages/eas-cli/schema/**
packages/eas-cli/graphql.schema.json
packages/build-tools/schema.graphql
node_modules
ts-declarations
-51
View File
@@ -1,51 +0,0 @@
module.exports = {
root: true,
extends: ['universe/node'],
plugins: ['async-protect', 'node'],
rules: {
'no-console': 'warn',
'no-constant-condition': ['warn', { checkLoops: false }],
'sort-imports': [
'warn',
{
ignoreDeclarationSort: true,
},
],
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'**/jest/**/*',
'**/__tests__/**/*',
'**/__mocks__/**/*',
'**/__integration-tests__/**/*',
],
},
],
'import/no-relative-packages': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
],
},
],
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'async-protect/async-suffix': 'error',
'node/no-sync': 'error',
},
overrides: [
{
files: ['**/__tests__/**/*.ts', '**/__mocks__/**/*.ts'],
rules: {
'async-protect/async-suffix': 'off',
},
},
],
};
@@ -12,6 +12,8 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
+9 -137
View File
@@ -1,7 +1,7 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
@@ -22,149 +22,21 @@ jobs:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload linux-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
asset_name: ${{ steps.arm.outputs.filename }}
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64,darwin-arm64
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm64
run: echo "filename=$(ls eas-*-arm64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload darwin-arm64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm64.outputs.filename }}
asset_name: ${{ steps.arm64.outputs.filename }}
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- id: x86
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-linux, build-mac, build-windows]
needs: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Build
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
@@ -192,12 +64,12 @@ jobs:
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Update changelog
working-directory: ./scripts
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env:
@@ -224,8 +96,8 @@ jobs:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
SLACK_TITLE: "Released eas-cli@${{ env.EAS_CLI_VERSION }}"
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
SLACK_USERNAME: 'EAS CLI CI'
SLACK_USERNAME: "EAS CLI CI"
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_FOOTER: ''
SLACK_FOOTER: ""
-21
View File
@@ -1,21 +0,0 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./.github/actions/setup-mise
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
clean-script: 'clean'
+1 -1
View File
@@ -20,6 +20,6 @@ jobs:
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+1 -1
View File
@@ -24,5 +24,5 @@ jobs:
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn install --immutable
- run: yarn typecheck
+20 -11
View File
@@ -22,11 +22,14 @@ jobs:
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install dependencies
run: yarn install --immutable
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- run: yarn typecheck
- run: |
git config --global user.email "[email protected]"
@@ -38,31 +41,37 @@ jobs:
- name: Run tests (affected)
# PRs: only run tests for changed packages + dependents.
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
run: yarn lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
run: yarn run -T lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (all)
# Pushes to main: run all package tests.
if: ${{ !matrix.coverage && github.event_name == 'push' }}
run: yarn lerna run test
run: yarn run -T lerna run test
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (coverage)
# Coverage on PRs and main; per-package reports are uploaded to Codecov.
if: ${{ matrix.coverage }}
run: yarn lerna run test -- -- --coverage
run: yarn run -T lerna run test -- --coverage
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files
- run: yarn lint
- run: yarn fmt:check
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint-changelog
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn install --immutable
- run: yarn typecheck
+1
View File
@@ -20,6 +20,7 @@ dist
# Node
node_modules
**/.yarn/install-state.gz
*-debug.log
*-error.log
+21
View File
@@ -0,0 +1,21 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "lf",
"ignorePatterns": [
".github",
"lerna.json",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"restriction": "off",
"style": "off",
"nursery": "off"
},
"ignorePatterns": [
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"**/__fixtures__/**",
".github/**",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
],
"plugins": ["import", "node", "typescript"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"no-console": "warn",
"no-constant-condition": [
"warn",
{
"checkLoops": false
}
],
"sort-imports": [
"warn",
{
"ignoreDeclarationSort": true
}
],
"curly": "warn",
"default": "off",
"named": "off",
"namespace": "off",
"no-cycle": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "Don't use lodash, it's heavy!"
}
]
}
],
"no-underscore-dangle": [
"error",
{
"allow": ["__typename"]
}
],
"async-protect/async-suffix": "error",
"node/no-sync": "error"
},
"overrides": [
{
"files": ["**/__tests__/**/*.ts", "**/__mocks__/**/*.ts"],
"rules": {
"async-protect/async-suffix": "off"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
}
+1 -2
View File
@@ -1,5 +1,4 @@
{
"eslint.enable": true,
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
@@ -7,4 +6,4 @@
"**/src/**/build/**": false,
"**/src/build/**": false,
}
}
}
-1
View File
@@ -1 +0,0 @@
save-prefix ""
+2
View File
@@ -0,0 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
-1
View File
@@ -3,4 +3,3 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
+47 -1
View File
@@ -6,10 +6,57 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
### 🛠 Breaking changes
- Drop support for Node 18 and Node 19. Require Node 20+. ([#3343](https://github.com/expo/eas-cli/pull/3343) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
### 🐛 Bug fixes
- Hide progress bar in build credits warning when usage reaches 100%. ([#3371](https://github.com/expo/eas-cli/pull/3371) by [@mackenco](https://github.com/mackenco))
@@ -21,7 +68,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
- List available options in non-interactive mode errors instead of generic "unable to select" messages, making CLI usable by agents and scripts. ([#3359](https://github.com/expo/eas-cli/pull/3359) by [@EvanBacon](https://github.com/EvanBacon))
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
+10 -14
View File
@@ -9,25 +9,21 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
### CLI & Configuration Packages
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
- Built on oclif (Open CLI Framework)
- Entry point: `packages/eas-cli/bin/run`
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
- Validates `eas.json` configuration files using Joi schemas
- Manages build, submit, and deploy profiles
- Supports JSON5 via golden-fleece
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
- Optimizes build caching for Expo CLI
### Build Execution Packages
4. **[packages/eas-build-job](./packages/eas-build-job)** - Build job definitions (published as `@expo/eas-build-job`)
- Defines all data structures for build operations (Job, BuildPhase, BuildMode, Platform, Workflow)
- Provides type definitions and validation schemas (Zod, Joi)
- Contains BuildPhase enum that defines the traditional build pipeline stages
@@ -35,7 +31,6 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
- **Foundation that all other build packages depend on**
5. **[packages/build-tools](./packages/build-tools)** - Build execution engine (published as `@expo/build-tools`)
- Orchestrates all build operations through `BuildContext<T extends Job>`
- Contains platform-specific builders: `androidBuilder()`, `iosBuilder()`, `runCustomBuildAsync()`
- Manages build phases, artifact uploading, caching, credentials
@@ -43,7 +38,6 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
- Integrates with GraphQL API
6. **[packages/steps](./packages/steps)** - Custom build workflow engine (published as `@expo/steps`)
- Framework for defining and executing custom build steps
- Key abstractions:
- `BuildWorkflow`: Orchestrates sequential step execution
@@ -55,7 +49,6 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
- Parses build configs from YAML/JSON
7. **[packages/local-build-plugin](./packages/local-build-plugin)** - Local build execution (published as `eas-cli-local-build-plugin`)
- Allows running EAS builds locally on developer machines
- Entry point: `packages/local-build-plugin/src/main.ts`
- Sets `EAS_BUILD_RUNNER=local-build-plugin` environment variable
@@ -69,7 +62,6 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
### Supporting Packages
9. **[packages/logger](./packages/logger)** - Bunyan-based structured logging (published as `@expo/logger`)
- Used by all build packages
10. **[packages/downloader](./packages/downloader)** - HTTP file downloading with retry logic (published as `@expo/downloader`)
@@ -106,8 +98,8 @@ yarn start-allow-unused
### Testing
```bash
yarn lerna run test # Run all tests across packages
yarn lerna run test -- --watch # Watch mode across packages (heavier)
yarn run -T lerna run test # Run all tests across packages
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
#
# If you use `yarn test`, remember to pass args through two separators:
# yarn test -- -- --watch
@@ -124,7 +116,8 @@ cd packages/steps && yarn test
```bash
yarn typecheck # TypeScript type check all packages
yarn lint # ESLint across all packages
yarn lint # Oxlint across all packages
yarn fmt:check # Oxfmt format check
```
### Running EAS CLI Locally
@@ -303,11 +296,14 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated with TypeScript and the linter before committing:
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run ESLint
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
```
### Logging
@@ -355,5 +351,5 @@ See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details.
## Important Notes
- **Node Version**: Requires Node.js (managed via Mise)
- **Package Manager**: Uses Yarn 1.22.21
- **Package Manager**: Uses Yarn 4.12.0
- **Compilation Target**: CommonJS with Node resolution
+1 -1
View File
@@ -69,7 +69,7 @@ See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making ch
## Testing
From the repository root, run `yarn lerna run test`. For a single package, run `yarn test` in that package directory.
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
## Releasing
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "17.0.0",
"version": "18.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+4 -1
View File
@@ -1,3 +1,6 @@
[env]
_.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
node = "20.19.4"
yarn = "1.22.22"
+10 -10
View File
@@ -6,32 +6,32 @@
],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint .",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild && ./scripts/bin/run release",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"eslint-plugin-async-protect": "3.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"oclif": "^4.22.85",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
}
},
"packageManager": "[email protected]"
}
@@ -1,5 +1,11 @@
{
"extends": "../../.eslintrc.js",
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"async-protect/async-suffix": "off",
"no-underscore-dangle": "off",
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
-1
View File
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
+16 -13
View File
@@ -1,19 +1,22 @@
{
"name": "@expo/build-tools",
"version": "18.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/build-tools"
},
"version": "17.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"resources",
"templates",
"bin"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"prewatch": "yarn gql",
@@ -21,7 +24,7 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
@@ -31,27 +34,26 @@
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "17.0.0",
"@expo/eas-build-job": "17.0.0",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/env": "^0.4.0",
"@expo/logger": "17.0.0",
"@expo/logger": "18.0.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "17.0.0",
"@expo/template-file": "17.0.0",
"@expo/turtle-spawn": "17.0.0",
"@expo/steps": "18.0.2",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
@@ -59,6 +61,7 @@
"lodash": "^4.17.21",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
"promise-limit": "^2.7.0",
@@ -19,3 +19,7 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -76,8 +76,8 @@ export async function resolveBuildPhaseErrorAsync(
const userFacingError =
error instanceof errors.UserFacingError
? error
: resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const isUnknownUserError =
@@ -17,7 +17,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
- switching to an image that supports that version under key "image"`,
'https://docs.expo.dev/build-reference/eas-json/'
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
@@ -30,7 +30,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
'https://docs.expo.dev/build-reference/eas-json/'
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
@@ -70,7 +70,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -85,7 +85,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -98,7 +98,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
new UserFacingError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
@@ -234,13 +234,19 @@ You are seeing this error because either:
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserFacingError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
),
},
{
@@ -32,7 +32,7 @@ export async function eagerBundleAsync({
export function shouldUseEagerBundle(metadata: Metadata | null | undefined): boolean {
return Boolean(
!metadata?.developmentClient &&
metadata?.sdkVersion &&
semver.satisfies(metadata?.sdkVersion, '>=52')
metadata?.sdkVersion &&
semver.satisfies(metadata?.sdkVersion, '>=52')
);
}
@@ -24,7 +24,8 @@ export async function installDependenciesAsync({
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
break;
}
case PackageManager.PNPM: {
@@ -44,7 +45,12 @@ export async function installDependenciesAsync({
];
}
} else {
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
}
break;
}
@@ -10,6 +10,7 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
@@ -53,6 +54,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
@@ -65,6 +67,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
@@ -19,7 +19,9 @@ import { createInstallNodeModulesBuildFunction } from './functions/installNodeMo
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -48,6 +50,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSetUpNpmrcBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
@@ -79,6 +82,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,210 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -0,0 +1,594 @@
import { vol } from 'memfs';
import {
parseFlowMetadata,
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 () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </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');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
{
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
errorMessage: null,
duration: 10500,
retryCount: 0,
tags: [],
properties: {},
},
{
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Tap failed',
duration: 5000,
retryCount: 0,
tags: [],
properties: {},
},
])
);
});
it('calculates retryCount from timestamp directory occurrences', 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'),
// 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');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', 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',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
it('extracts tags from JUnit properties and separates from other properties', 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">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="tags" value="e2e, smoke"/>',
' </properties>',
' </testcase>',
' </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');
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('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>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tmp/maestro-reports-abc123/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Failed</failure>',
' </testcase>',
' </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'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
])
);
});
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, () => {
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'home',
status: 'passed',
duration: 10500,
errorMessage: null,
tags: [],
properties: {},
},
]);
});
it('parses a failed testcase with failure message', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Element not visible</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'login',
status: 'failed',
duration: 5000,
errorMessage: 'Element not visible',
tags: [],
properties: {},
},
]);
});
it('extracts properties from testcase', 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">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
});
it('extracts tags from "tags" property as comma-separated values', 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">',
' <properties>',
' <property name="tags" value="smoke, critical, auth"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('returns empty tags when no "tags" property exists (older Maestro)', 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">',
' <properties>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual([]);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('parses multiple testcases across multiple testsuites (shards)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed' }),
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
])
);
});
it('parses multiple JUnit XML files in the same directory', async () => {
vol.fromJSON({
'/junit/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
});
it('handles missing time attribute (defaults to 0)', 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" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('handles invalid time attribute (defaults to 0)', 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="not_a_number" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBeNull();
});
it('extracts error message from <error> element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <error>Runtime exception occurred</error>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBe('Runtime exception occurred');
});
it('returns empty array when directory does not exist', async () => {
const results = await parseJUnitTestCases('/nonexistent');
expect(results).toEqual([]);
});
it('returns empty array when no XML files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([]);
});
it('skips invalid XML files gracefully', async () => {
vol.fromJSON({
'/junit/bad.xml': 'not xml at all',
'/junit/good.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 parseJUnitTestCases('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
});
it('handles testcase with no properties element', 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'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({});
});
});
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createReadIpaInfoBuildFunction, readIpaInfoAsync } from '../readIpaInfo';
const NO_INFO_PLIST_IPA_BASE64 =
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9SZWFkbWUudHh0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGhlbGxvClBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1JlYWRtZS50eHRVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAA7gAAAAAA';
const MISSING_BUNDLE_VERSION_IPA_BASE64 =
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBBQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9JbmZvLnBsaXN0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGWOUUvDMBSF3/srYt6b6/RFJMtw7QaFooV2go+jybZgm4Tkard/b7IKor7ec+73Hb46jwP5VD5oa5Z0wW4pUaa3Upvjku66bf5AVyLjN+VL0b01G+IGHZA0u3VdFYTmAE/ODQqg7ErS1FXbkcgA2DxTQk+I7hFgmia2Ty3W2zEVAzTeOuXxUkdYHh+YREmjZqb/mhOvUvcoMkL4u7qIYrv+MHJQlVQG9UErzyHdUx7Qx90iapg678ekRBUwyjl8Z38x7cl6fJ2F7bXyD7dgd+z+B8Bh3sPhulZkX1BLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAxQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL0luZm8ucGxpc3RVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAAwwEAAAAA';
const IPA_FIXTURE_SOURCE_PATH = path.join(__dirname, 'fixtures/SmallestAppExample.ipa');
async function writeIpaFileAsync(filePath: string, base64Data: string): Promise<void> {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, base64Data, { encoding: 'base64' });
}
describe(readIpaInfoAsync, () => {
it('reads bundle identifier and versions from fixture ipa', async () => {
await expect(readIpaInfoAsync(IPA_FIXTURE_SOURCE_PATH)).resolves.toEqual({
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
});
});
it('throws when Info.plist cannot be found in IPA', async () => {
const ipaPath = '/tmp/no-info.ipa';
await writeIpaFileAsync(ipaPath, NO_INFO_PLIST_IPA_BASE64);
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
});
it('throws when required plist fields are missing', async () => {
const ipaPath = '/tmp/missing-bundle-version.ipa';
await writeIpaFileAsync(ipaPath, MISSING_BUNDLE_VERSION_IPA_BASE64);
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
/Failed to read IPA info: .*CFBundleVersion/
);
});
});
describe(createReadIpaInfoBuildFunction, () => {
it('sets build step outputs', async () => {
const readIpaInfo = createReadIpaInfoBuildFunction();
const globalContext = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
const buildStep = readIpaInfo.createBuildStepFromFunctionCall(globalContext, {
callInputs: {
ipa_path: IPA_FIXTURE_SOURCE_PATH,
},
});
await buildStep.executeAsync();
expect(buildStep.outputById.bundle_identifier.value).toBe('dev.expo.SmallestAppExample');
expect(buildStep.outputById.bundle_short_version.value).toBe('1.0');
expect(buildStep.outputById.bundle_version.value).toBe('1');
});
});
@@ -0,0 +1,273 @@
import { Client } from '@urql/core';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
'<?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 JUNIT_FAIL = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n');
const FLOW_AI = JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
});
describe(createReportMaestroTestResultsFunction, () => {
let mockMutationFn: jest.Mock;
let mockGraphqlClient: Client;
beforeEach(() => {
mockMutationFn = jest.fn();
mockGraphqlClient = {
mutation: jest.fn().mockReturnValue({
toPromise: mockMutationFn,
}),
} as unknown as Client;
});
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
...overrides?.staticContextContent,
},
});
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
junit_report_directory: '/junit',
tests_directory: '/tests',
...overrides?.callInputs,
},
});
}
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,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.workflowJobId).toBe('job-uuid-123');
expect(variables.input.testCaseResults).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'PASSED',
errorMessage: null,
duration: 10000,
retryCount: 0,
properties: {},
}),
]);
});
it('sends tags and properties from JUnit XML', 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">',
' <properties>',
' <property name="testCaseId" value="TC-001"/>',
' <property name="priority" value="high"/>',
' <property name="tags" value="e2e"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
expect(variables.input.testCaseResults[0].properties).toEqual({
testCaseId: 'TC-001',
priority: 'high',
});
});
it('reports failed flow with correct status and errorMessage', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_FAIL,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({
status: 'FAILED',
errorMessage: 'Tap failed',
duration: 5000,
})
);
});
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
// not optional), so it's always present in workflow jobs. graphqlClient is already
// initialized with the token in BuildContext constructor.
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
const step = createStep({
callInputs: { junit_report_directory: '' },
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const step = createStep({ env: {} });
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when no JUnit files found', async () => {
const step = createStep();
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('does not throw when GraphQL returns error', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
error: { message: 'Something went wrong' },
});
await createStep().executeAsync();
});
it('does not throw when mutation rejects', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockRejectedValue(new Error('Network error'));
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
},
});
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,177 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,97 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -71,7 +71,7 @@ async function uploadApplicationArchivesAsync({
}): Promise<void> {
const applicationArchivePatternOrPath =
ctx.job.platform === Platform.ANDROID
? ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}'
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
: resolveIosArtifactPath(ctx.job);
const applicationArchives = await findArtifacts({
rootDir: workingDirectory,
@@ -24,6 +24,7 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
@@ -81,6 +82,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
@@ -348,6 +353,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
@@ -405,6 +414,9 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
@@ -426,7 +438,7 @@ async function withCleanDeviceAsync<TResult>({
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
@@ -450,28 +462,81 @@ async function withCleanDeviceAsync<TResult>({
break;
}
case 'android': {
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(`Starting Android Emulator ${localDeviceName}...`);
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
localDeviceIdentifier = serialId;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
@@ -502,14 +567,14 @@ async function withCleanDeviceAsync<TResult>({
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
@@ -0,0 +1,264 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
path: string;
status: 'passed' | 'failed';
errorMessage: string | null;
duration: number; // milliseconds
retryCount: number;
tags: string[];
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
tags: string[];
properties: Record<string, string>;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
// Ensure single-element arrays are always arrays
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
entries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
if (xmlFiles.length === 0) {
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
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[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
}
return results;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// 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;
}
return relative;
}
@@ -0,0 +1,147 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import fs from 'fs-extra';
import path from 'node:path';
import StreamZip from 'node-stream-zip';
import { z } from 'zod';
import bplistParser from 'bplist-parser';
import plist from 'plist';
export type IpaInfo = {
bundleIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
};
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
export function createReadIpaInfoBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_ipa_info',
name: 'Read IPA Info',
__metricsId: 'eas/read_ipa_info',
inputProviders: [
BuildStepInput.createProvider({
id: 'ipa_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'bundle_identifier',
required: true,
}),
BuildStepOutput.createProvider({
id: 'bundle_short_version',
required: true,
}),
BuildStepOutput.createProvider({
id: 'bundle_version',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
`IPA file not found: ${ipaPath}`
);
}
const ipaInfo = await readIpaInfoAsync(ipaPath);
outputs.bundle_identifier.set(ipaInfo.bundleIdentifier);
outputs.bundle_short_version.set(ipaInfo.bundleShortVersion);
outputs.bundle_version.set(ipaInfo.bundleVersion);
},
});
}
export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
try {
const infoPlistBuffer = await readInfoPlistBufferFromIpaAsync(ipaPath);
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
const bundleIdentifier = infoPlist.CFBundleIdentifier;
if (typeof bundleIdentifier !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
);
}
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
if (typeof bundleShortVersion !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
);
}
const bundleVersion = infoPlist.CFBundleVersion;
if (typeof bundleVersion !== 'string') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
);
}
return {
bundleIdentifier,
bundleShortVersion,
bundleVersion,
};
} catch (error) {
if (error instanceof UserFacingError) {
throw error;
}
throw new UserFacingError(
'EAS_READ_IPA_INFO_FAILED',
`Failed to read IPA info: ${(error as Error).message}`,
{ cause: error }
);
}
}
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
if (isBinaryPlist) {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
const parsedBinaryPlist = parsedBinaryPlists[0];
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
'Invalid binary plist in IPA'
);
}
return parsedBinaryPlist as Record<string, unknown>;
}
return plist.parse(data.toString('utf8')) as Record<string, unknown>;
}
async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer> {
const zip = new StreamZip.async({ file: ipaPath });
try {
const entries = Object.values(await zip.entries());
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
if (!infoPlistEntry) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
}
return await zip.entryData(infoPlistEntry.name);
} finally {
await zip.close();
}
}
@@ -0,0 +1,120 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
createWorkflowDeviceTestCaseResults(input: $input) {
id
}
}
}
`);
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
passed: 'PASSED',
failed: 'FAILED',
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'report_maestro_test_results',
name: 'Report Maestro Test Results',
__metricsId: 'eas/report_maestro_test_results',
inputProviders: [
BuildStepInput.createProvider({
id: 'junit_report_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'tests_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
logger.info('Not running in a workflow job, skipping test results report');
return;
}
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
if (!junitDirectory) {
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
return;
}
const testCaseResults = flowResults.flatMap(f => {
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
if (!status) {
return [];
}
return [
{
name: f.name,
path: f.path,
status,
errorMessage: f.errorMessage,
duration: f.duration,
retryCount: f.retryCount,
tags: f.tags,
properties: f.properties,
},
];
});
const result = await ctx.graphqlClient
.mutation(CREATE_MUTATION, {
input: {
workflowJobId,
testCaseResults,
},
})
.toPromise();
if (result.error) {
logger.error({ error: result.error }, 'GraphQL error creating test case results');
return;
}
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
} catch (error) {
logger.error({ err: error }, 'Failed to create test case results');
}
},
});
}
@@ -9,6 +9,9 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -72,25 +75,78 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}
);
logger.info('Creating emulator device');
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
logger.info('Starting emulator device');
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId,
});
logger.info(`${deviceName} is ready.`);
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -105,27 +161,70 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
for (let i = 0; i < count; i++) {
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let cloneSerialId = null;
try {
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info('Starting emulator device');
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
cloneSerialId = startResult.serialId;
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: cloneSerialId,
env,
timeoutMs,
logger,
});
logger.info(`${cloneIdentifier} is ready.`);
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
logger.warn(
{ err },
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (cloneSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: cloneSerialId,
deviceName: cloneIdentifier,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: cloneIdentifier,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
}
}
},
@@ -1,3 +1,5 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildStepInput,
@@ -12,6 +14,8 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -101,34 +105,21 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info('Reading App information...');
const appResponse = await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
);
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
relationships: {
app: {
data: {
type: 'apps',
id: appleAppIdentifier,
},
},
},
},
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
});
const buildUploadId = buildUploadResponse.data.id;
@@ -234,7 +225,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
while (Date.now() - waitingForBuildStartedAt < 10 * 60 * 1000 /* 10 minutes */) {
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
@@ -246,7 +240,14 @@ export function createUploadToAscBuildFunction(): BuildFunction {
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
@@ -265,6 +266,50 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
@@ -279,6 +324,36 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -11,7 +11,43 @@ type ApiSchema = {
};
};
const AscErrorResponseSchema = z.object({
errors: z
.array(
z.object({
id: z.string().optional(),
status: z.string().optional(),
code: z.string().optional(),
title: z.string().optional(),
detail: z.string().optional(),
source: z.unknown().optional(),
})
)
.min(1),
});
const GetApi = {
'/v1/apps': {
path: z.object({}),
request: z.object({
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
return opts.includes('bundleId') && opts.includes('name');
}),
limit: z.number().int().min(1).max(200).optional(),
}),
response: z.object({
data: z.array(
z.object({
type: z.literal('apps'),
id: z.string(),
attributes: z.object({
bundleId: z.string(),
name: z.string(),
}),
})
),
}),
},
'/v1/apps/:id': {
path: z.object({
id: z.string(),
@@ -222,6 +258,13 @@ const PatchApi = {
},
} satisfies ApiSchema;
export type AscApiClientGetApi = {
[Path in keyof typeof GetApi]: {
request: z.input<(typeof GetApi)[Path]['request']>;
response: z.output<(typeof GetApi)[Path]['response']>;
};
};
export type AscApiClientPostApi = {
[Path in keyof typeof PostApi]: {
request: z.input<(typeof PostApi)[Path]['request']>;
@@ -236,6 +279,17 @@ export type AscApiClientPatchApi = {
};
};
export class AscApiRequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
options?: { cause?: unknown }
) {
super(message, { cause: options?.cause });
}
}
export class AscApiClient {
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
private readonly token: string;
@@ -346,12 +400,31 @@ export class AscApiClient {
if (!response.ok) {
const text = await response.text();
const parsedAscErrorResponse = await asyncResult(
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
);
if (parsedAscErrorResponse.ok) {
throw new AscApiRequestError(
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
response.status,
parsedAscErrorResponse.value,
{ cause: response }
);
}
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
cause: response,
});
}
const json = await response.json();
const text = await response.text();
const parsedJson = await asyncResult((async () => JSON.parse(text))());
if (!parsedJson.ok) {
throw new Error(
`Malformed JSON response from App Store Connect (${response.status}): ${text}`
);
}
const json = parsedJson.value;
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
@@ -0,0 +1,126 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
AscApiClient,
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
} from './AscApiClient';
export namespace AscApiUtils {
export async function getAppInfoAsync({
client,
appleAppIdentifier,
}: {
client: Pick<AscApiClient, 'getAsync'>;
appleAppIdentifier: string;
}): Promise<AscApiClientGetApi['/v1/apps/:id']['response']> {
try {
return await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
} catch (error) {
const notFoundErrors =
error instanceof AscApiRequestError && error.status === 404
? error.responseJson.errors
: [];
const isAppNotFoundError =
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
if (!isAppNotFoundError) {
throw error;
}
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
(visibleAppsSummary
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
: ''),
{
cause: error,
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
}
export async function createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
}: {
client: Pick<AscApiClient, 'postAsync'>;
appleAppIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
try {
return await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
relationships: {
app: {
data: {
type: 'apps',
id: appleAppIdentifier,
},
},
},
},
});
} catch (error) {
const errors =
error instanceof AscApiRequestError && error.status === 409
? error.responseJson.errors
: [];
const isDuplicateVersionError =
errors.length > 0 &&
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
if (isDuplicateVersionError) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
{
cause: error,
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
}
);
}
throw error;
}
}
}
async function getVisibleAppsSummaryAsync(
client: Pick<AscApiClient, 'getAsync'>
): Promise<string | null> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: 10,
});
if (appsResponse.data.length === 0) {
return ' (none)';
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -1,6 +1,6 @@
import nock from 'nock';
import { AscApiClient } from '../AscApiClient';
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
// nock needs real fetch implementation
jest.unmock('node-fetch');
@@ -280,4 +280,54 @@ describe(AscApiClient, () => {
});
expect(scope.isDone()).toBeTruthy();
});
it('throws AscApiRequestError for structured ASC error payload', async () => {
const appId = '1491144534';
const responseFixture = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
title:
'The provided entity includes an attribute with a value that has already been used',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(409, responseFixture);
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toBeInstanceOf(AscApiRequestError);
});
it('throws regular Error for non-structured ASC error payload', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(503, 'Service unavailable');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.not.toBeInstanceOf(AscApiRequestError);
});
it('throws controlled error when success response body is not valid JSON', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(200, 'not-json');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
});
});
@@ -0,0 +1,190 @@
import { AscApiRequestError } from '../AscApiClient';
import { AscApiUtils } from '../AscApiUtils';
describe('AscApiUtils', () => {
describe('getAppInfoAsync', () => {
it('returns app info when lookup succeeds', async () => {
const response = {
data: {
type: 'apps',
id: '1491144534',
attributes: {
name: 'Example App',
bundleId: 'com.example.app',
},
},
} as const;
const client = {
getAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1491144534' })
).resolves.toEqual(response);
});
it('throws UserFacingError with visible apps when app id is not found', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
detail: "There is no resource of type 'apps' with id '1234567890'",
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockResolvedValueOnce({
data: [
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
],
}),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
),
})
);
});
it('rethrows original not-found error when app-list lookup fails', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const listingError = new Error('listing failed');
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockRejectedValueOnce(listingError),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toBe(notFoundError);
});
});
describe('createBuildUploadAsync', () => {
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
const duplicateError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(duplicateError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
message: expect.stringContaining('Increment Build Number'),
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
})
);
});
it('returns response when upload initialization succeeds', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).resolves.toEqual(response);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
},
{
status: '409',
code: 'SOME_OTHER_ERROR',
},
],
};
const mixedError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(mixedError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toBe(mixedError);
});
});
});
@@ -1,4 +1,5 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
@@ -17,6 +18,8 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
export namespace AndroidEmulatorUtils {
const RETRY_INTERVAL_MS = 1_000;
export const defaultSystemImagePackage = `system-images;android-30;default;${
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
}`;
@@ -352,10 +355,15 @@ export namespace AndroidEmulatorUtils {
export async function waitForReadyAsync({
serialId,
env,
timeoutMs = 3 * 60 * 1_000,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
logger?: bunyan;
}): Promise<void> {
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
await retryAsync(
async () => {
const { stdout } = await spawn(
@@ -367,17 +375,48 @@ export namespace AndroidEmulatorUtils {
if (!stdout.startsWith('1')) {
throw new Error(`Emulator (${serialId}) boot has not completed.`);
}
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
if (!hasNetworkConnection) {
throw new Error(`Emulator (${serialId}) network is not ready.`);
}
},
{
// Retry every second for 3 minutes.
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retries,
retryIntervalMs: RETRY_INTERVAL_MS,
},
logger,
}
);
}
async function hasNetworkConnectionAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
if (networkReadyCheckCommand) {
const customNetworkCheckResult = await asyncResult(
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
);
return customNetworkCheckResult.ok;
}
const netcatResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
)
);
return netcatResult.ok;
}
export async function collectLogsAsync({
serialId,
env,
@@ -419,18 +458,13 @@ export namespace AndroidEmulatorUtils {
return { outputPath };
}
export async function deleteAsync({
export async function stopAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
await retryAsync(
@@ -443,12 +477,40 @@ export namespace AndroidEmulatorUtils {
{
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retryIntervalMs: RETRY_INTERVAL_MS,
},
}
);
}
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
export async function deleteAsync({
serialId,
deviceName,
env,
}: {
serialId?: AndroidDeviceSerialId;
deviceName?: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
let resolvedDeviceName = deviceName;
if (!resolvedDeviceName && serialId) {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
| AndroidVirtualDeviceName
| undefined;
}
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
const serialIdToStop =
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
if (serialIdToStop) {
await stopAsync({ serialId: serialIdToStop, env });
}
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
}
export async function startScreenRecordingAsync({
@@ -9,6 +9,7 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
});
expect(stdout).toContain('data');
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
await AndroidEmulatorUtils.stopAsync({
serialId,
env: process.env,
});
await asyncResult(emulatorPromise);
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
expect(stdoutClone).toContain('data');
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await asyncResult(emulatorPromiseClone);
}, 60_000);
it('should delete a running emulator by device name', async () => {
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: process.env,
});
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
await asyncResult(emulatorPromise);
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
expect(devices.map(device => device.serialId)).not.toContain(serialId);
await expect(fs.promises.access(avdPath)).rejects.toThrow();
}, 60_000);
it('should retry when first startup forces network check command failure', async () => {
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
let attemptCounter = 0;
let sawNetworkNotReadyError = false;
await retryAsync(
async attemptCount => {
attemptCounter += 1;
const shouldFailNetworkCheck = attemptCount === 0;
const envForAttempt: NodeJS.ProcessEnv = {
...process.env,
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
? 'echo "network not ready" && exit 1'
: 'echo "network ready"',
};
let serialId: AndroidDeviceSerialId | null = null;
let emulatorPromise: Promise<unknown> | null = null;
try {
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: envForAttempt,
timeoutMs: 30_000,
logger: createMockLogger({ logToConsole: true }),
});
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await emulatorPromise;
} catch (err: any) {
if (err?.message?.includes('network is not ready')) {
sawNetworkNotReadyError = true;
}
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (cleanupError) {
console.warn('Cleanup failed during retry test', cleanupError);
}
if (emulatorPromise) {
await emulatorPromise;
}
throw err;
}
},
{
logger: createMockLogger({ logToConsole: true }),
retryOptions: {
retries: 1,
retryIntervalMs: 1_000,
},
}
);
expect(sawNetworkNotReadyError).toBe(true);
expect(attemptCounter).toBe(2);
}, 360_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -0,0 +1,161 @@
import spawn from '@expo/turtle-spawn';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
timeoutMs: 60_000,
});
expect(mockedRetryAsync).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
retryOptions: expect.objectContaining({
retries: 59,
retryIntervalMs: 1_000,
}),
})
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
});
it('throws when network is unavailable despite completed boot', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc') {
throw new Error('network unreachable');
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await expect(
AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
})
).rejects.toThrow('network is not ready');
});
it('uses overridden network readiness command when provided', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'exit 0') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: {
...process.env,
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
},
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'exit 0'],
expect.objectContaining({
env: expect.objectContaining({
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
}),
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
expect.anything()
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[2] === 'emu' && args[3] === 'kill') {
return { stdout: '', stderr: '' } as any;
}
if (args[0] === 'devices') {
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.stopAsync({
serialId: 'emulator-5554' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
env: process.env,
});
});
});
describe(AndroidEmulatorUtils.deleteAsync, () => {
it('stops by serial id and then deletes the AVD', async () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await AndroidEmulatorUtils.deleteAsync({
serialId: 'emulator-5554' as any,
deviceName: 'eas-simulator-1' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
['delete', 'avd', '-n', 'eas-simulator-1'],
{
env: process.env,
}
);
});
});
});
@@ -1,10 +0,0 @@
{
"extends": "../../.eslintrc.js",
"rules": {
"async-protect/async-suffix": "off",
"import/no-extraneous-dependencies": ["off"],
"no-console": "off",
"no-underscore-dangle": "off",
"no-void": "off"
}
}
@@ -0,0 +1,15 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"async-protect/async-suffix": "off",
"no-console": "off",
"no-underscore-dangle": "off",
"no-void": "off"
}
}
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
+20 -20
View File
@@ -1,40 +1,41 @@
{
"name": "create-eas-build-function",
"version": "18.0.3",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
"react",
"react-native"
],
"homepage": "https://docs.expo.dev",
"license": "BSD-3-Clause",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/create-eas-build-function"
},
"version": "17.0.0",
"bin": {
"create-eas-build-function": "./build/index.js"
},
"main": "build",
"description": "Create functions for use in EAS Build custom builds.",
"license": "BSD-3-Clause",
"keywords": [
"expo",
"react-native",
"react"
],
"homepage": "https://docs.expo.dev",
"author": "Expo <[email protected]>",
"bin": "./build/index.js",
"files": [
"build",
"templates"
],
"main": "build",
"publishConfig": {
"access": "public"
},
"scripts": {
"prepack": "yarn run clean && yarn run build",
"lint": "eslint .",
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
"test": "echo 'No tests yet.'",
"watch": "yarn run build:dev -w",
"build:dev": "ncc build ./src/index.ts -o build/",
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register",
"typecheck": "tsc",
"clean": "rimraf ./build/ *.tsbuildinfo"
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "17.0.0"
"@expo/steps": "18.0.2"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
@@ -46,12 +47,11 @@
"chalk": "4.1.2",
"fs-extra": "^11.2.0",
"ora": "^6.3.1",
"oxlint": "1.43.0",
"prompts": "^2.4.2",
"rimraf": "3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"update-check": "^1.5.4"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,16 +1,16 @@
{
"name": "custom-js-function",
"version": "1.0.0",
"main": "./build/index.js",
"type": "commonjs",
"devDependencies": {
"@vercel/ncc": "^0.38.1",
"@babel/cli": "^7.24.8",
"@babel/core": "^7.24.9",
"@babel/preset-env": "^7.24.8"
},
"main": "./build/index.js",
"scripts": {
"babel": "babel src --out-dir babel",
"build": "yarn babel && ncc build ./babel/index.js -o build/ --minify --no-cache --no-source-map-register"
},
"devDependencies": {
"@babel/cli": "^7.24.8",
"@babel/core": "^7.24.9",
"@babel/preset-env": "^7.24.8",
"@vercel/ncc": "^0.38.1"
}
}
@@ -2,17 +2,17 @@
"name": "custom-ts-function",
"version": "1.0.0",
"description": "",
"main": "./build/index.js",
"keywords": [],
"author": "",
"type": "commonjs",
"main": "./build/index.js",
"scripts": {
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register"
},
"keywords": [],
"author": "",
"devDependencies": {
"@vercel/ncc": "^0.38.1",
"@expo/steps": "^1.0.119",
"@types/node": "20.14.11",
"typescript": "^5.5.3",
"@expo/steps": "^1.0.119"
"@vercel/ncc": "^0.38.1",
"typescript": "^5.5.3"
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"extends": "../../.eslintrc.js",
"rules": {
"async-protect/async-suffix": "off"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"async-protect/async-suffix": "off"
}
}
-1
View File
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
+7 -7
View File
@@ -1,29 +1,29 @@
{
"name": "@expo/downloader",
"version": "18.0.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/downloader"
},
"version": "17.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"dependencies": {
"fs-extra": "^11.2.0",
"got": "11.8.5"
+1 -3
View File
@@ -12,7 +12,5 @@
"rootDir": "./src",
"outDir": "./dist"
},
"include": [
"src/**/*"
]
"include": ["src/**/*"]
}
@@ -0,0 +1,9 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
]
}
@@ -48,6 +48,7 @@ Update your **app.json** to enable the EAS build cache provider:
## How It Works
The build cache provider:
1. Hooks into Expo CLI's build process
2. Uploads build cache artifacts to EAS infrastructure
3. Downloads cached artifacts on subsequent builds
+29 -28
View File
@@ -1,9 +1,35 @@
{
"name": "eas-build-cache-provider",
"version": "18.0.2",
"description": "A build cache provider plugin for the Expo CLI",
"version": "17.0.0",
"author": "Expo <[email protected]>",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-build-cache-provider"
},
"files": [
"/build"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"test": "jest --passWithNoTests",
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/config": "11.0.10",
@@ -22,37 +48,12 @@
"@types/babel__code-frame": "7.0.3",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"jest": "29.7.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
},
"engines": {
"node": ">=20.0.0"
},
"homepage": "https://github.com/expo/eas-cli",
"license": "MIT",
"main": "build/index.js",
"types": "build/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-build-cache-provider"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build && yarn build",
"test": "jest --passWithNoTests",
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
},
"files": [
"/build"
],
"publishConfig": {
"access": "public"
}
}
@@ -1,19 +0,0 @@
module.exports = {
extends: ['universe/shared/typescript-analysis'],
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './packages/eas-build-cache-provider/tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
},
},
],
};
@@ -0,0 +1,17 @@
{
"extends": ["../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
]
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../../.eslintrc.js"
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
]
}
-1
View File
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
+13 -13
View File
@@ -1,40 +1,40 @@
{
"name": "@expo/eas-build-job",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-build-job"
},
"version": "17.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"dependencies": {
"@expo/logger": "18.0.1",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
},
"dependencies": {
"@expo/logger": "17.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
}
}
@@ -340,6 +340,60 @@ describe('Android.JobSchema', () => {
expect(error).toBeFalsy();
});
test('can set hooks with arbitrary names', () => {
const job = {
mode: BuildMode.BUILD,
type: Workflow.GENERIC,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
secrets,
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Android.JobSchema.validate(job, joiOptions);
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('can set hooks in custom mode', () => {
const customBuildJob = {
mode: BuildMode.CUSTOM,
type: Workflow.UNKNOWN,
platform: Platform.ANDROID,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://expo.dev/builds/123',
},
projectRootDirectory: '.',
customBuildConfig: {
path: 'production.android.yml',
},
hooks: {
before_install_node_modules: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions);
expect(value).toMatchObject(customBuildJob);
expect(error).toBeFalsy();
});
test('can set github trigger options', () => {
const job = {
mode: BuildMode.BUILD,
@@ -0,0 +1,30 @@
import { UserFacingError } from '../errors';
describe(UserFacingError, () => {
it('supports docsUrl in options', () => {
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBeUndefined();
});
it('supports cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', { cause });
expect(error.cause).toBe(cause);
});
it('supports docsUrl and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
cause,
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBe(cause);
});
});
@@ -92,6 +92,107 @@ describe('Generic.JobZ', () => {
expect(Generic.JobZ.parse(job)).toEqual(job);
});
it('accepts hooks with arbitrary names', () => {
const job: Generic.Job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
steps: [
{
id: 'step1',
name: 'Step 1',
run: 'echo Hello, world!',
shell: 'sh',
},
],
secrets: {
robotAccessToken: 'token',
environmentSecrets: [
{
name: 'secret-name',
value: 'secret-value',
type: EnvironmentSecretType.STRING,
},
],
},
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
builderEnvironment: {
image: 'macos-sonoma-14.5-xcode-15.4',
node: '20.15.1',
corepack: false,
env: {
KEY1: 'value1',
},
},
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
expect(Generic.JobZ.parse(job)).toEqual(job);
});
it('errors when hook step definition is invalid', () => {
const job = {
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://github.com/expo/expo.git',
gitCommitHash: '1234567890',
gitRef: null,
},
hooks: {
before_install_node_modules: [
{
id: 'missing-run-or-uses',
},
],
},
steps: [
{
id: 'step1',
name: 'Step 1',
run: 'echo Hello, world!',
shell: 'sh',
},
],
secrets: {
robotAccessToken: 'token',
environmentSecrets: [
{
name: 'secret-name',
value: 'secret-value',
type: EnvironmentSecretType.STRING,
},
],
},
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
builderEnvironment: {
image: 'macos-sonoma-14.5-xcode-15.4',
node: '20.15.1',
corepack: false,
env: {
KEY1: 'value1',
},
},
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
expect(() => Generic.JobZ.parse(job)).toThrow(ZodError);
});
it('errors when steps are not provided', () => {
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
projectArchive: {
@@ -84,6 +84,66 @@ describe('Ios.JobSchema', () => {
expect(error).toBeFalsy();
});
test('valid generic job with hooks', () => {
const genericJob = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
hooks: {
before_install_node_modules: [
{
id: 'before-install',
run: 'echo before install',
shell: 'sh',
},
],
my_custom_hook: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
expect(value).toMatchObject(genericJob);
expect(error).toBeFalsy();
});
test('valid resign job with hooks', () => {
const genericJob = {
mode: BuildMode.RESIGN,
secrets: {
buildCredentials,
},
type: Workflow.UNKNOWN,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.NONE,
},
resign: {
applicationArchiveSource: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000/a.ipa',
},
},
hooks: {
before_install_node_modules: [],
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
expect(value).toMatchObject(genericJob);
expect(error).toBeFalsy();
});
test('valid custom build job with metadataLocation', () => {
const customBuildJob = {
mode: BuildMode.CUSTOM,
+4
View File
@@ -13,6 +13,8 @@ import {
EnvSchema,
EnvironmentSecret,
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -106,6 +108,7 @@ export interface Job {
customBuildConfig?: {
path: string;
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
@@ -172,6 +175,7 @@ export const JobSchema = Joi.object({
buildType: Joi.string().valid(...Object.values(BuildType)),
username: Joi.string(),
hooks: HooksSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
+16 -1
View File
@@ -2,7 +2,7 @@ import Joi from 'joi';
import { z } from 'zod';
import { BuildPhase, BuildPhaseResult } from './logs';
import { validateSteps } from './step';
import { Step, StepZ, validateSteps } from './step';
export enum BuildMode {
BUILD = 'build',
@@ -142,6 +142,21 @@ export const EnvironmentSecretZ = z.object({
type: z.nativeEnum(EnvironmentSecretType),
});
export type Hooks = Record<string, Step[]>;
export const HooksSchema = Joi.object().pattern(
Joi.string(),
Joi.array()
.items(Joi.any())
.required()
.custom(steps => {
if (steps.length === 0) {
return steps;
}
return validateSteps(steps);
}, 'steps validation')
);
export const HooksZ = z.record(z.string(), z.array(StepZ));
export interface Cache {
disabled: boolean;
clear: boolean;
+5 -2
View File
@@ -54,12 +54,15 @@ export class BuildError extends Error {
}
export class UserFacingError extends Error {
public docsUrl?: string;
constructor(
public errorCode: string,
public message: string,
public docsUrl?: string
options?: { docsUrl?: string; cause?: unknown }
) {
super(message);
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
}
}
+2
View File
@@ -5,6 +5,7 @@ import {
ArchiveSourceSchemaZ,
BuildTrigger,
EnvironmentSecretZ,
HooksZ,
StaticWorkflowInterpolationContextZ,
} from './common';
import { StepZ } from './step';
@@ -45,6 +46,7 @@ export namespace Generic {
initiatingUserId: z.string(),
appId: z.string(),
hooks: HooksZ.optional(),
steps: z.array(StepZ).min(1),
outputs: z.record(z.string(), z.string()).optional(),
});
+1
View File
@@ -11,6 +11,7 @@ export {
Env,
EnvironmentSecret,
EnvironmentSecretType,
Hooks,
Workflow,
Platform,
Cache,
+4
View File
@@ -13,6 +13,8 @@ import {
EnvSchema,
EnvironmentSecret,
EnvironmentSecretsSchema,
Hooks,
HooksSchema,
Platform,
StaticWorkflowInterpolationContext,
StaticWorkflowInterpolationContextZ,
@@ -120,6 +122,7 @@ export interface Job {
customBuildConfig?: {
path: string;
};
hooks?: Hooks;
steps?: Step[];
outputs?: Record<string, string>;
@@ -206,6 +209,7 @@ export const JobSchema = Joi.object({
applicationArchivePath: Joi.string(),
username: Joi.string(),
hooks: HooksSchema,
experimental: Joi.object({
prebuildCommand: Joi.string(),
-64
View File
@@ -1,64 +0,0 @@
const rootEslintrc = require('../../.eslintrc.js');
module.exports = {
rules: {
'no-restricted-properties': [
'warn',
{
object: 'it',
property: 'only',
message: 'it.only should not be committed to main.',
},
{
object: 'test',
property: 'only',
message: 'test.only should not be committed to main.',
},
{
object: 'describe',
property: 'only',
message: 'describe.only should not be committed to main.',
},
],
'no-restricted-imports': [
'error',
{
paths: [
...rootEslintrc.rules['no-restricted-imports'][1].paths,
{
name: 'ora',
message: 'Import ora from src/ora.ts instead.',
},
],
},
],
'graphql/template-strings': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/named-operations': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
'graphql/required-fields': [
'error',
{
env: 'apollo',
schemaJson: require('./graphql.schema.json'),
requiredFields: ['id'],
},
],
'graphql/capitalized-type-name': [
'error',
{
schemaJson: require('./graphql.schema.json'),
},
],
},
plugins: ['graphql'],
};
+75
View File
@@ -0,0 +1,75 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
},
{
"name": "graphql",
"specifier": "eslint-plugin-graphql"
}
],
"rules": {
"no-restricted-properties": [
"warn",
{
"object": "it",
"property": "only",
"message": "it.only should not be committed to main."
},
{
"object": "test",
"property": "only",
"message": "test.only should not be committed to main."
},
{
"object": "describe",
"property": "only",
"message": "describe.only should not be committed to main."
}
],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "Don't use lodash, it's heavy!"
},
{
"name": "ora",
"message": "Import ora from src/ora.ts instead."
}
]
}
],
"graphql/template-strings": [
"error",
{
"env": "apollo",
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
}
],
"graphql/named-operations": [
"error",
{
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
}
],
"graphql/required-fields": [
"error",
{
"env": "apollo",
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json",
"requiredFields": ["id"]
}
],
"graphql/capitalized-type-name": [
"error",
{
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
}
]
}
}
+17 -4
View File
@@ -13,6 +13,7 @@ The main CLI tool for Expo Application Services (EAS). Published as `eas-cli` on
## Development Commands
### Running Locally
```bash
# From repository root:
yarn eas <command>
@@ -26,6 +27,7 @@ easd build --help
```
### Build & Development
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
@@ -35,22 +37,26 @@ yarn typecheck-for-build # Type check for production build
```
### Testing
```bash
yarn test # Run all tests
yarn test <path-to-file> # Run specific test file
```
### GraphQL Code Generation
```bash
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:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
### oclif Commands
```bash
yarn oclif readme # Auto-generate README.md from command metadata
```
@@ -81,6 +87,7 @@ Available context types:
- Provides: methods to interact with server-side env vars
Example command structure:
```typescript
import EasCommand from '../../commandUtils/EasCommand';
@@ -88,7 +95,7 @@ export default class Build extends EasCommand {
static override description = 'start a build';
static override ContextOptions = {
...EasCommand.ContextOptions.LoggedIn, // Requires auth
...EasCommand.ContextOptions.LoggedIn, // Requires auth
...EasCommand.ContextOptions.ProjectConfig, // Requires project
};
@@ -120,12 +127,14 @@ export default class Build extends EasCommand {
**Location**: `src/graphql/`
**Client**: `ExpoGraphqlClient` (urql-based wrapper)
- Automatic retry logic with exponential backoff
- Cache management (cache-first vs network-only)
- Transient error handling
- Authentication via Bearer token or session secret
**Structure**:
```
src/graphql/
├── generated.ts # Generated TypeScript types
@@ -135,15 +144,14 @@ src/graphql/
```
**Making GraphQL Requests**:
```typescript
// In a command with LoggedIn context:
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
nonInteractive: false,
});
const result = await graphqlClient
.query(MyQuery, { variables: { id: projectId } })
.toPromise();
const result = await graphqlClient.query(MyQuery, { variables: { id: projectId } }).toPromise();
if (result.error) {
throw new Error(result.error.message);
@@ -157,6 +165,7 @@ const data = result.data;
**Client**: `ApiV2Client` in `src/api.ts`
**Base URLs**:
- Production: `https://api.expo.dev`
- Staging: `https://staging-api.expo.dev` (set `EXPO_STAGING=1`)
- Local: `http://127.0.0.1:3000` (set `EXPO_LOCAL=1`)
@@ -202,6 +211,7 @@ src/
## Common Patterns
### Working with eas.json
```typescript
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
@@ -214,6 +224,7 @@ const buildProfile = easJson.build?.[profileName];
```
### Project Context
```typescript
// In a command with ProjectConfig context:
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
@@ -243,6 +254,7 @@ const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
### Error Handling
Extend `CommandError` or use specific error classes in `src/commandUtils/errors.ts`:
```typescript
import { CommandError } from '../commandUtils/errors';
@@ -264,6 +276,7 @@ throw new CommandError('BUILD_FAILED', 'The build failed due to...');
- Utilities: `ts-mockito`, `mockdate`
Example test:
```typescript
import { vol } from 'memfs';
import nock from 'nock';
+97 -94
View File
@@ -165,10 +165,11 @@ log in with your Expo account
```
USAGE
$ eas account:login [-s]
$ eas account:login [-s] [-b]
FLAGS
-s, --sso Login with SSO
-b, --browser Login with your browser
-s, --sso Login with SSO
DESCRIPTION
log in with your Expo account
@@ -177,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -194,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -216,7 +217,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -233,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -247,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v17.0.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -299,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -320,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -340,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -360,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -383,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -424,7 +425,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -443,7 +444,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +461,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -479,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -499,7 +500,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -520,7 +521,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -555,7 +556,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -607,7 +608,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -636,7 +637,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -662,7 +663,7 @@ 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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -716,7 +717,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -735,7 +736,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -754,7 +755,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -771,7 +772,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -792,7 +793,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -813,7 +814,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -835,7 +836,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -855,7 +856,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -877,7 +878,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -899,7 +900,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -932,7 +933,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -955,7 +956,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -976,7 +977,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/v17.0.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -993,7 +994,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1011,7 +1012,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1039,7 +1040,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1064,7 +1065,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1085,7 +1086,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1106,7 +1107,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1143,7 +1144,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1163,7 +1164,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1184,7 +1185,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1205,7 +1206,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1219,7 +1220,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1233,7 +1234,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v17.0.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1263,7 +1264,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1288,7 +1289,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1310,7 +1311,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1335,7 +1336,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1361,7 +1362,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1383,7 +1384,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1405,7 +1406,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1437,7 +1438,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1481,7 +1482,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1511,7 +1512,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1576,10 +1577,11 @@ log in with your Expo account
```
USAGE
$ eas login [-s]
$ eas login [-s] [-b]
FLAGS
-s, --sso Login with SSO
-b, --browser Login with your browser
-s, --sso Login with SSO
DESCRIPTION
log in with your Expo account
@@ -1619,7 +1621,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1636,7 +1638,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1653,7 +1655,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1704,7 +1706,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/v17.0.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1718,7 +1720,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1741,7 +1743,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1764,7 +1766,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1782,7 +1784,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1817,7 +1819,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v17.0.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1839,7 +1841,8 @@ FLAGS
--emit-metadata Emit "eas-update-metadata.json" in the bundle folder with detailed information about
the generated updates
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development". Required for
projects using Expo SDK 55 or greater.
--input-dir=<value> [default: dist] Location of the bundle
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
@@ -1857,7 +1860,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1870,14 +1873,14 @@ USAGE
FLAGS
-p, --platform=(android|ios|all) [default: all] Platform to configure
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development".
--non-interactive Run the command in non-interactive mode.
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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1898,7 +1901,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1922,7 +1925,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1947,7 +1950,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1983,7 +1986,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2010,7 +2013,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2038,7 +2041,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2059,7 +2062,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2079,7 +2082,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2100,7 +2103,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/v17.0.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2121,7 +2124,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2141,7 +2144,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2159,7 +2162,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2181,7 +2184,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2198,7 +2201,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2318,7 +2321,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2338,7 +2341,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2362,7 +2365,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2402,7 +2405,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2427,7 +2430,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2455,7 +2458,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2475,7 +2478,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2497,5 +2500,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/v17.0.0/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/v18.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+6 -13
View File
@@ -1,17 +1,10 @@
#!/usr/bin/env node
const oclif = require('@oclif/core');
const path = require('path');
(async () => {
const oclif = require('@oclif/core');
const project = path.join(__dirname, '..', 'tsconfig.json');
// Keep dev entrypoint failures verbose for local debugging.
oclif.settings.debug = true;
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
await oclif.execute({ development: true, dir: __dirname });
})();
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
require('@oclif/core')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
-1
View File
@@ -1,6 +1,5 @@
import type { Config } from '@jest/types';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
+59 -59
View File
@@ -1,22 +1,57 @@
{
"name": "eas-cli",
"version": "18.1.0",
"description": "EAS command line tool",
"version": "17.0.0",
"keywords": [
"cli",
"eas",
"expo"
],
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-cli"
},
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"files": [
"/bin",
"/build",
"/oclif.manifest.json",
"/schema"
],
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"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",
"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"
},
"dependencies": {
"@expo/apple-utils": "2.1.13",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "17.0.0",
"@expo/eas-json": "17.0.0",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-json": "18.0.2",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "17.0.0",
"@expo/logger": "18.0.1",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -28,10 +63,10 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "17.0.0",
"@expo/steps": "18.0.2",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
@@ -123,6 +158,7 @@
"axios": "1.11.0",
"eslint-plugin-graphql": "4.0.0",
"express": "4.20.0",
"jest": "29.7.0",
"memfs": "3.4.13",
"mockdate": "3.0.5",
"nock": "13.4.0",
@@ -131,23 +167,13 @@
"ts-node": "10.9.2",
"typescript": "5.5.4"
},
"engines": {
"node": ">=20.0.0"
},
"files": [
"/bin",
"/build",
"/oclif.manifest.json",
"/schema"
],
"homepage": "https://github.com/expo/eas-cli",
"keywords": [
"expo",
"eas",
"cli"
],
"license": "MIT",
"oclif": {
"additionalHelpFlags": [
"-h"
],
"additionalVersionFlags": [
"-v"
],
"bin": "eas",
"commands": "./build/commands",
"plugins": [
@@ -171,6 +197,9 @@
"device": {
"description": "manage Apple devices for Internal Distribution"
},
"env": {
"description": "manage project and account environment variables"
},
"metadata": {
"description": "manage store configuration"
},
@@ -183,9 +212,6 @@
"secret": {
"description": "manage project and account secrets"
},
"env": {
"description": "manage project and account environment variables"
},
"update": {
"description": "manage individual updates"
},
@@ -196,10 +222,6 @@
"description": "manage workflows"
}
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
},
"update": {
"node": {
"version": "20.19.4"
@@ -213,34 +235,12 @@
}
}
},
"additionalHelpFlags": [
"-h"
],
"additionalVersionFlags": [
"-v"
]
"warn-if-update-available": {
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n",
"timeoutInDays": 0.5
}
},
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/eas-cli"
},
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"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"
"engines": {
"node": ">=20.0.0"
}
}
+1 -1
View File
@@ -2,6 +2,6 @@ const spawn = require('@expo/spawn-async');
(async () => {
if (!process.env.CLI_SIZE_CHECK) {
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
await spawn('yarn', ['run', '-T', 'oclif', 'manifest'], { stdio: 'inherit' });
}
})();
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
EAS_CLI_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
PACKAGE_JSON_PATH="$EAS_CLI_DIR/package.json"
# Define local packages and their directory names
# Only include packages that both exist in packages/ and are dependencies of the packages we care about
# We start with direct dependencies of eas-cli and their own local dependencies
PACKAGES=(
"@expo/eas-json:eas-json"
"@expo/eas-build-job:eas-build-job"
"@expo/logger:logger"
"@expo/steps:steps"
)
# Function to rewrite dependencies in a package.json file
rewrite_deps() {
local PKG_JSON=$1
local IS_EAS_CLI=$2
for package in "${PACKAGES[@]}" ; do
KEY="${package%%:*}"
DIR="${package##*:}"
# Determine the relative path. If IS_EAS_CLI we go one level deeper because oclif builds in `tmp` in current directory.
if [[ "$IS_EAS_CLI" == "true" ]]; then
REL_PATH="file:../../../$DIR"
else
REL_PATH="file:../$DIR"
fi
# Replace version with relative path
# Using # delimiter for sed to handle slashes
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s#\"$KEY\": \".*\"#\"$KEY\": \"$REL_PATH\"#g" "$PKG_JSON"
else
sed -i "s#\"$KEY\": \".*\"#\"$KEY\": \"$REL_PATH\"#g" "$PKG_JSON"
fi
done
}
# Update eas-cli/package.json
echo "Updating eas-cli/package.json"
rewrite_deps "$PACKAGE_JSON_PATH" "true"
# Update other relevant local packages
# We iterate the PACKAGES list itself to find their locations and update them
# This ensures we only touch packages that are part of the dependency graph we care about
WORKSPACE_ROOT="$(cd "$EAS_CLI_DIR/.." && pwd)"
for package in "${PACKAGES[@]}" ; do
DIR="${package##*:}"
PKG_JSON="$WORKSPACE_ROOT/$DIR/package.json"
if [[ -f "$PKG_JSON" ]]; then
echo "Updating $DIR/package.json"
rewrite_deps "$PKG_JSON" "false"
fi
done
+1 -1
View File
@@ -27,7 +27,7 @@ if cmp -s graphql.schema.json graphql.schema.json.bak; then
else
echo "GraphQL schema has changed on the server."
echo "Building eas-cli and its dependencies"
yarn lerna run build --scope eas-cli --include-dependencies
yarn run -T lerna run build --scope eas-cli --include-dependencies
# If there are no errors, then the schema is backwards compatible
exit 0
fi
-40
View File
@@ -1,40 +0,0 @@
module.exports = {
extends: ['universe/shared/typescript-analysis'],
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './packages/eas-cli/tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'@typescript-eslint/prefer-nullish-coalescing': ['warn', { ignorePrimitives: true }],
'@typescript-eslint/no-confusing-void-expression': 'warn',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: false,
},
],
'@typescript-eslint/no-floating-promises': 'error',
'no-void': ['warn', { allowAsStatement: true }],
'no-return-await': 'off',
'@typescript-eslint/return-await': ['error', 'always'],
'@typescript-eslint/no-confusing-non-null-assertion': 'warn',
'@typescript-eslint/no-extra-non-null-assertion': 'warn',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/prefer-includes': 'warn',
'@typescript-eslint/prefer-readonly': 'warn',
'@typescript-eslint/prefer-string-starts-ends-with': 'warn',
'@typescript-eslint/prefer-ts-expect-error': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
},
},
],
};
+51
View File
@@ -0,0 +1,51 @@
{
"extends": ["../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
},
{
"name": "graphql",
"specifier": "eslint-plugin-graphql"
}
],
"rules": {
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"typescript/prefer-nullish-coalescing": [
"warn",
{
"ignorePrimitives": true,
"ignoreIfStatements": true
}
],
"typescript/no-confusing-void-expression": "warn",
"typescript/await-thenable": "error",
"typescript/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"typescript/no-floating-promises": "error",
"no-void": [
"warn",
{
"allowAsStatement": true
}
],
"no-return-await": "off",
"typescript/return-await": ["warn", "error-handling-correctness-only"],
"typescript/no-confusing-non-null-assertion": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/prefer-as-const": "warn",
"typescript/prefer-includes": "warn",
"typescript/prefer-ts-expect-error": "warn",
"typescript/no-unnecessary-type-assertion": "warn"
}
}
@@ -133,11 +133,21 @@ export function mockTestCommand<T extends Command>(
argv: string[],
ctx: any
): T {
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
const config = getMockOclifConfig(__dirname);
const cmd = new commandConstructor(argv, config);
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export function getMockOclifConfig(root = __dirname): Config {
const config = new Config({ root });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more