Compare commits

..
Author SHA1 Message Date
Expo CI cacd48bf66 v18.10.0 2026-05-04 09:45:14 +00:00
Stanisław Chmiela bc213a51b9 [eas-cli] Create portable project archives (#3234)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

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

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

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

# How

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

# Test Plan

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

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

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

yarn build

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# How

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

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

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

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

# Test Plan

```bash
cd packages/eas-cli

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

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

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

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

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

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

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

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

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

---------

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

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

* [eas-cli] add changelog

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

---------

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

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

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

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

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

* address comments

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

* address comments 2

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

---------

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

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

## Validation

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

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

# How

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

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

# Test Plan

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

* ASC app link GQL mutations

* ASC utils

* status command

* disconnect command

* select or create ASC API key util function

* connect command

* add oclif connections topic

* gql codegen

* changelog

* regenerate readme

* dedicated app store api key purpose

* prompt user to choose the api key role

* remove unncecessary defensiveness in getting links remoteAppStoreConnectApp

* handle GQL error when invalid connection

* format

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

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

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

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

## Test Plan

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

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

* more metadata

* all error codes

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

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

# How

Updated the event query and formatter.

# Test Plan

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

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

* clean up

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

* add test

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

* fix import order

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

* address comment

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

---------

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

* Add an update:insights command

* Add an --insights flag to update:view

* Refactor shared util

* Improve naming

* Add a create table helper

* Pull date formating to a util

* Improve readability

* Validate insights params

* Add a test to ensure the table renders

* Add channel insights

* Fix lint

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

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

* address comment

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

---------

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

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

* address comments

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

* address comments

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

* clean up

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

* clean up

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

* clean up

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

* address comment

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

---------

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

* format

* Update CHANGELOG.md

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

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

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

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

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

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

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

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

* Fix parsing

* Improved error message

* self review

* Increase test coverage

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

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

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

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

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

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

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

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

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

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

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

* Update default values and checks

* Remove logger from resolveEasCommandPrefixAndEnvAsync

* Fix CI

* revert uneeded change

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

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

---------

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

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

# How

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

# Test Plan

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

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

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

# Why

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

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

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

# Test Plan

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

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

* [eas-cli] Tweak skip-bundler wording

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

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

* Add changelog entry

* Move towards cpuinfo approach

* Align var names

* fmt

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

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

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

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

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

* Update error message

* Update CHANGELOG for recent build-tools changes

* Fix blank line on changelog

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

* Update CHANGELOG

* format fix

* test: restore startAndroidEmulator helper signature

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

* refactor build-tools emulator host support check naming

* format startAndroidEmulator test

* Update err code to align with rename

* Add docsUrl to user error

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

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

# Why

Provide CLI commands to display Observe metrics and events.

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

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

DESCRIPTION
  display app performance metrics grouped by app version

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

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

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

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

DESCRIPTION
  display individual app performance events ordered by metric value

DESCRIPTION
  display individual app performance events ordered by metric value

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

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

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

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

# How

New commands and GraphQL queries.

# Test Plan

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

* tests
2026-04-10 10:35:03 +02:00
190 changed files with 23712 additions and 1654 deletions

No files matched your search

+63 -4
View File
@@ -8,18 +8,76 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
### 🧹 Chores
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
### 🧹 Chores
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
@@ -27,6 +85,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.5.0",
"version": "18.10.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.5.0",
"version": "18.10.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.5.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
+16 -2
View File
@@ -16,6 +16,7 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -51,6 +52,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -146,10 +148,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
await runFastlaneGym(ctx, {
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -171,6 +173,18 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -0,0 +1,72 @@
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,4 @@
import {
BuildJob,
EasCliNpmTags,
Env,
Metadata,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -14,7 +7,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { isAtLeastNpm7Async } from '../utils/packageManager';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -121,33 +114,6 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
+2 -5
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
+3 -1
View File
@@ -173,7 +173,9 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
}
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
@@ -22,6 +22,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -45,6 +49,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -70,6 +78,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -97,6 +109,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -0,0 +1,128 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,6 +58,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -94,6 +96,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -131,6 +135,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -192,6 +198,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -210,6 +218,7 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -226,6 +235,7 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -243,6 +253,7 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
@@ -0,0 +1,149 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
usePrecompiledModules = true,
}: {
expoVersion?: string;
usePrecompiledModules?: boolean;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
});
});
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
}),
})
);
expect(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '55.0.18',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -3,6 +3,7 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<void> {
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
cwd: workspacePath,
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -122,6 +131,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
+9
View File
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -59,6 +64,8 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -86,6 +94,7 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
+82 -1
View File
@@ -1,9 +1,14 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -35,3 +42,77 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
// return PRECOMPILED_MODULES_BASE_URL;
// }
//
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
// return PRECOMPILED_MODULES_BASE_URL.replace(
// `${parsedUrl.protocol}//${parsedUrl.host}`,
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
// );
// }
@@ -0,0 +1,27 @@
import { getEasFunctionGroups } from '../easFunctionGroups';
describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
const ctx = {
hasBuildJob: () => false,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(['eas/maestro_test']);
});
it('includes eas/build for build jobs', () => {
const ctx = {
hasBuildJob: () => true,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});
});
@@ -9,6 +9,8 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
@@ -18,7 +20,9 @@ import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
@@ -28,11 +32,14 @@ import {
createRestoreBuildCacheFunction,
} from './functions/restoreBuildCache';
import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
@@ -47,10 +54,14 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -67,6 +78,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -81,6 +94,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,200 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { createEasBuildBuildFunctionGroup } from '../build';
function createMockBuildToolsContext(
overrides: Partial<{
platform: Platform;
simulator: boolean;
buildCredentials: Record<string, unknown>;
}> = {}
): CustomBuildContext<BuildJob> {
return {
job: {
platform: overrides.platform ?? Platform.ANDROID,
simulator: overrides.simulator ?? false,
secrets: overrides.buildCredentials
? { buildCredentials: overrides.buildCredentials }
: undefined,
},
} as unknown as CustomBuildContext<BuildJob>;
}
describe(createEasBuildBuildFunctionGroup, () => {
describe('working_directory input', () => {
it('does not set working directory on steps when not provided (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
for (const step of steps) {
// Without working_directory, only installPods would have a relative dir (iOS only).
// On Android, no step should have a relative working directory.
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
}
});
it('sets working directory on all steps except checkout (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
it('sets working directory on all steps except checkout (iOS simulator)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
const targetNamesInput = configureIosVersionStep?.inputs?.find(
input => input.id === 'target_names'
);
expect(configureIosVersionStep).toBeDefined();
expect(targetNamesInput?.rawValue).toBe(
'${{ steps.configure_ios_credentials.outputs.target_names }}'
);
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
});
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
});
it('throws for generic jobs (no platform)', () => {
const buildToolsContext = {
job: { platform: undefined },
} as unknown as CustomBuildContext<BuildJob>;
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
'Build function group is not supported in generic jobs.'
);
});
});
@@ -1,5 +1,12 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
createBuildStepsFromFunctionGroupCall: globalCtx => {
inputProviders: [
BuildStepInput.createProvider({
id: 'working_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
const workingDirectory = inputs.working_directory?.getValue({
interpolationContext: globalCtx.getInterpolationContext(),
}) as string | undefined;
if (buildToolsContext.job.platform === Platform.IOS) {
if (buildToolsContext.job.simulator) {
return createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
});
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
];
}
function createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const resolveAppleTeamIdFromCredentials =
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'resolve_apple_team_id_from_credentials',
workingDirectory,
});
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
},
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: './ios',
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
);
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -199,11 +250,20 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
},
}),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -215,24 +275,29 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -242,6 +307,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -249,6 +315,7 @@ function createStepsForAndroidBuildWithoutCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -257,6 +324,7 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -264,18 +332,26 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -286,24 +362,29 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
function createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -313,6 +394,7 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -320,6 +402,7 @@ function createStepsForAndroidBuildWithCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -328,6 +411,7 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -335,21 +419,33 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx
globalCtx,
{ workingDirectory }
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
runGradle,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -359,8 +455,10 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
];
}
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { configureCredentialsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
describe(configureIosCredentialsFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
});
it('exports target_names from prepared credentials', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: createTestIosJob() },
}),
{
callInputs: {
credentials: createRawCredentials(),
},
}
);
await buildStep.executeAsync();
expect(configureCredentialsAsync).toHaveBeenCalled();
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
});
});
@@ -0,0 +1,286 @@
import { UserError } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { updateVersionsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
import { configureIosVersionFunction } from '../configureIosVersion';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
function createBuildStep({
callInputs = {},
job = createTestIosJob(),
}: {
callInputs?: Record<string, unknown>;
job?: ReturnType<typeof createTestIosJob>;
} = {}) {
return configureIosVersionFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job },
}),
{ callInputs }
);
}
describe(configureIosVersionFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
});
it('uses target_names without an explicit credentials input', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 'widget'],
build_number: '42',
},
});
await buildStep.executeAsync();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: '42',
appVersion: undefined,
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses credentials only to preserve the existing fallback path', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
callInputs: {
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses the implicit credentials default fallback from the job secrets', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
callInputs: {
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('prefers target_names when both target_names and credentials are provided', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
const buildStep = createBuildStep({
callInputs: {
target_names: ['input-target'],
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['input-target'],
buildConfiguration: 'Release',
}
);
});
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: {
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
},
});
const configureIosCredentialsStep =
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
});
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
globalCtx,
{
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
app_version: '1.2.3',
},
}
);
await new BuildWorkflow(globalCtx, {
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
buildFunctions: {},
}).executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
JSON.stringify(['app', 'widget'])
);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
configureIosVersionStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('accepts an empty target_names array without crashing', async () => {
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const buildStep = createBuildStep({
callInputs: {
target_names: [],
app_version: '1.2.3',
},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: [],
buildConfiguration: 'Release',
}
);
});
it('rejects invalid target_names', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 123],
app_version: '1.2.3',
},
});
const promise = buildStep.executeAsync();
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
});
it('uses the corrected App version error text', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app'],
app_version: 'invalid-semver',
},
});
await expect(buildStep.executeAsync()).rejects.toThrow(
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
);
});
});
@@ -0,0 +1,290 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { runEasCliCommand } from '../../../utils/easCli';
import { createEasDeployBuildFunction } from '../deploy';
jest.mock('../../../utils/easCli', () => ({
runEasCliCommand: jest.fn(),
}));
const mockDeployStdout = JSON.stringify({
url: 'https://example.dev',
production: { url: 'https://example.prod' },
aliases: [{ url: 'https://example.alias' }],
identifier: 'abc',
dashboardUrl: 'https://expo.dev/dashboard',
});
describe(createEasDeployBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
alias: 'preview-link',
prod: true,
source_maps: false,
},
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
);
expect(runEasCliCommand).toHaveBeenCalledWith({
args: [
'deploy',
'--non-interactive',
'--json',
'--export-dir',
'dist',
'--alias',
'preview-link',
'--prod',
],
options: expect.objectContaining({
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
}),
});
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
});
it('forwards workflow step env to the eas deploy process', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: true },
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--source-maps']),
})
);
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
HOME: '/tmp/home',
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
}),
}),
})
);
});
it('throws an actionable error message when deploy command fails', async () => {
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
let errorMessage = '';
try {
await buildStep.executeAsync();
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toContain('Deploy command failed.');
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
expect(errorMessage).toContain(
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
);
expect(errorMessage).toContain('Original error: deploy failed');
});
it('passes custom export_dir to eas deploy', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { export_dir: 'web-build' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--export-dir', 'web-build']),
})
);
});
it('does not pass --source-maps when source_maps is false', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: false },
});
await buildStep.executeAsync();
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
expect(args).not.toContain('--source-maps');
});
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from('not-json'),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything() }),
expect.stringContaining('Failed to parse')
);
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
});
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
const partialDeployStdout = JSON.stringify({
url: 'https://example.dev-only',
dashboardUrl: 'https://expo.dev/dashboard-only',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(partialDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
});
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
const stdout = JSON.stringify({
url: 'https://example.deployment',
aliases: [{ url: 'https://example.alias-first' }],
identifier: 'partial-alias',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(stdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
});
});
@@ -0,0 +1,122 @@
jest.mock('../../../utils/project', () => ({
runExpoCliCommand: jest.fn().mockResolvedValue({}),
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
}));
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
import { runExpoCliCommand } from '../../../utils/project';
import { createEasExportBuildFunction } from '../export';
describe(createEasExportBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
});
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running export command: expo export --output-dir dist --platform web'
);
expect(runExpoCliCommand).toHaveBeenCalledWith({
packageManager: PackageManager.NPM,
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
options: {
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
stdio: 'pipe',
},
});
expect(buildStep.outputById.export_dir.value).toBe('dist');
});
it('passes optional flags matching expo export CLI', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
output_dir: 'web-out',
dev: true,
minify: false,
dump_assetmap: true,
ssg: false,
api_only: true,
platform: 'web',
},
});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'export',
'--output-dir',
'web-out',
'--platform',
'web',
'--dev',
'--no-minify',
'--dump-assetmap',
'--no-ssg',
'--api-only',
],
})
);
expect(buildStep.outputById.export_dir.value).toBe('web-out');
});
it('uses resolved package manager (e.g. yarn)', async () => {
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({ packageManager: PackageManager.YARN })
);
});
it('throws when export command fails', async () => {
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
});
});
@@ -1,6 +1,8 @@
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
@@ -803,3 +805,22 @@ describe(parseJUnitTestCases, () => {
expect(results[0].properties).toEqual({});
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
});
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
});
it('throws when source has no xml files', async () => {
vol.fromJSON({ '/tmp/r/.keep': '' });
await expect(
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
).rejects.toThrow();
});
});
@@ -0,0 +1,473 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockedSpawn = jest.mocked(spawn);
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub copyLatestAttemptXml so tests that don't care about it
// don't hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries all flows on failure', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('writes final report via copyLatestAttemptXml on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
});
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
jest.mock('../../utils/ios/xcactivitylog');
describe(parseXcactivitylogFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
proxyBaseUrl: 'https://cache.example.com',
})
);
});
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
xclogparserVersion: undefined,
})
);
});
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: 'my-app/ios/Build',
workspace_path: 'my-app/ios',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
})
);
});
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: '/abs/derived',
workspace_path: '/abs/workspace',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: '/abs/derived',
workspacePath: '/abs/workspace',
})
);
});
});
@@ -0,0 +1,77 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAppConfig } from '../../../utils/appConfig';
import { createReadAppConfigBuildFunction } from '../readAppConfig';
jest.mock('../../../utils/appConfig');
describe(createReadAppConfigBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('reads and logs app configuration', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({
logger,
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
});
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
await buildStep.executeAsync();
expect(readAppConfig).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: globalContext.defaultWorkingDirectory,
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
logger: stepLogger,
sdkVersion: '54.0.0',
})
);
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
expect(stepLogger.info).toHaveBeenCalledWith(
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
);
const firstOutput = buildStep.outputById.app_config.value;
expect(firstOutput).toBeDefined();
expect(JSON.parse(firstOutput!)).toEqual({
name: 'my-app',
slug: 'my-app-slug',
version: '1.2.3',
});
});
it('does not throw if reading app config fails', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
});
it('exports app fields as returned by app config', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
await buildStep.executeAsync();
const secondOutput = buildStep.outputById.app_config.value;
expect(secondOutput).toBeDefined();
expect(JSON.parse(secondOutput!)).toEqual({
name: 'my-app',
slug: 123,
});
});
});
@@ -0,0 +1,50 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAndLogPackageJson } from '../../../utils/project';
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
jest.mock('../../../utils/project');
describe(createReadPackageJsonBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('logs package.json contents', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
await buildStep.executeAsync();
expect(readAndLogPackageJson).toHaveBeenCalledWith(
stepLogger,
globalContext.defaultWorkingDirectory
);
expect(buildStep.outputById.package_json.value).toBe(
JSON.stringify({ name: 'app', version: '1.0.0' })
);
});
it('does not throw if reading package.json fails', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
throw new Error('failed to read package.json');
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(stepLogger.error).toHaveBeenCalledWith({
err: expect.any(Error),
});
expect(buildStep.outputById.package_json.value).toBeUndefined();
});
});
@@ -34,7 +34,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
@@ -212,4 +211,64 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
it('fails early on Linux when emulator host support check fails', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
it('continues startup on Linux when emulator host support check succeeds', async () => {
await createStep().executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('skips emulator host support check when opt out env var is enabled', async () => {
const step = createStep(undefined, {
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
false
);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: undefined,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
});
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: '/android/sdk/root',
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'/android/sdk/root/emulator/emulator',
['-accel-check'],
{
env: expect.any(Object),
}
);
});
});
@@ -1,5 +1,10 @@
import { Ios } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import assert from 'assert';
import { configureCredentialsAsync } from '../utils/ios/configure';
@@ -26,9 +31,14 @@ export function configureIosCredentialsFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
outputProviders: [
BuildStepOutput.createProvider({
id: 'target_names',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
@@ -50,6 +60,7 @@ export function configureIosCredentialsFunction(): BuildFunction {
inputs.build_configuration.value as string | undefined
),
});
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
stepCtx.logger.info('Successfully configured iOS credentials');
},
@@ -1,7 +1,8 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import assert from 'assert';
import semver from 'semver';
import { z } from 'zod';
import { updateVersionsAsync } from '../utils/ios/configure';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -17,10 +18,15 @@ export function configureIosVersionFunction(): BuildFunction {
inputProviders: [
BuildStepInput.createProvider({
id: 'credentials',
required: true,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
defaultValue: '${ eas.job.secrets.buildCredentials }',
}),
BuildStepInput.createProvider({
id: 'target_names',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'build_configuration',
required: false,
@@ -38,19 +44,6 @@ export function configureIosVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
assert(stepCtx.global.staticContext.job, 'Job is not defined');
const job = stepCtx.global.staticContext.job as Ios.Job;
@@ -59,8 +52,9 @@ export function configureIosVersionFunction(): BuildFunction {
const appVersion =
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
if (appVersion && !semver.valid(appVersion)) {
throw new Error(
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
);
}
@@ -71,6 +65,33 @@ export function configureIosVersionFunction(): BuildFunction {
return;
}
const targetNamesInput = inputs.target_names.value;
let targetNames: string[];
if (targetNamesInput !== undefined) {
const parsed = z.array(z.string()).safeParse(targetNamesInput);
if (!parsed.success) {
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
'"target_names" input must be an array of strings.',
{ cause: parsed.error }
);
}
targetNames = parsed.data;
} else {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
targetNames = Object.keys(credentials.targetProvisioningProfiles);
}
stepCtx.logger.info('Setting iOS version...');
if (buildNumber) {
stepCtx.logger.info(`Build number: ${buildNumber}`);
@@ -87,7 +108,7 @@ export function configureIosVersionFunction(): BuildFunction {
appVersion,
},
{
targetNames: Object.keys(credentials.targetProvisioningProfiles),
targetNames,
buildConfiguration: resolveBuildConfiguration(
job,
inputs.build_configuration.value as string | undefined
@@ -0,0 +1,167 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { runEasCliCommand } from '../../utils/easCli';
export function createEasDeployBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'deploy',
name: 'Deploy',
__metricsId: 'eas/deploy',
inputProviders: [
BuildStepInput.createProvider({
id: 'alias',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'prod',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'source_maps',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
// Match eas/export default `output_dir`
BuildStepInput.createProvider({
id: 'export_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'deploy_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'deploy_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_deployment_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_identifier',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_dashboard_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_alias_url',
required: false,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const alias = inputs.alias.value as string | undefined;
const prod = inputs.prod.value as boolean | undefined;
const sourceMaps = inputs.source_maps.value as boolean | undefined;
const exportDir = inputs.export_dir.value as string;
const deployCommand = getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
});
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
try {
const result = await runEasCliCommand({
args: deployCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
const deployJson = result.stdout.toString();
outputs.deploy_json.set(deployJson);
const parsedDeploymentOutput = parseDeploymentOutput({
deployJson,
logger: stepsCtx.logger,
});
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'unknown error';
throw new UserError(
'EAS_DEPLOY_FAILED',
'Deploy command failed.\n\n' +
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
`Original error: ${errorMessage}`,
{ cause: error }
);
}
},
});
}
function getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
}: {
exportDir: string;
alias?: string;
prod?: boolean;
sourceMaps?: boolean;
}): string[] {
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
if (alias) {
deployCommand.push('--alias', alias);
}
if (prod) {
deployCommand.push('--prod');
}
if (sourceMaps) {
deployCommand.push('--source-maps');
}
return deployCommand;
}
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
deploy_url?: string;
deploy_deployment_url?: string;
deploy_identifier?: string;
deploy_dashboard_url?: string;
deploy_alias_url?: string;
} {
try {
const deployObject = JSON.parse(deployJson);
return {
deploy_url:
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
deploy_deployment_url: deployObject.url,
deploy_identifier: deployObject.identifier,
deploy_dashboard_url: deployObject.dashboardUrl,
deploy_alias_url: deployObject.aliases?.[0]?.url,
};
} catch (error: unknown) {
logger.warn(
{ err: error },
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
);
return {};
}
}
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
throw new Error('Eager bundle is not supported for SDK version < 52');
}
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
| string
| undefined;
@@ -0,0 +1,140 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { resolvePackageManager } from '../../utils/packageManager';
import { runExpoCliCommand } from '../../utils/project';
export function createEasExportBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'export',
name: 'Export',
__metricsId: 'eas/export',
inputProviders: [
BuildStepInput.createProvider({
id: 'output_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'dev',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'minify',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'dump_assetmap',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ssg',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'platform',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'web',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'export_dir',
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const outputDir = inputs.output_dir.value as string;
const dev = inputs.dev.value as boolean | undefined;
const minify = inputs.minify.value as boolean | undefined;
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
const ssg = inputs.ssg.value as boolean | undefined;
const apiOnly = inputs.api_only.value as boolean | undefined;
const platform = inputs.platform.value as string;
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const exportCommand = getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
});
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
try {
await runExpoCliCommand({
packageManager,
args: exportCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
stdio: 'pipe',
},
});
outputs.export_dir.set(outputDir);
} catch (error) {
throw new UserError(
'EXPO_EXPORT_FAILED',
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
},
});
}
function getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
}: {
outputDir: string;
dev?: boolean;
minify?: boolean;
dumpAssetmap?: boolean;
ssg?: boolean;
apiOnly?: boolean;
platform: string;
}): string[] {
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
if (dev) {
exportCommand.push('--dev');
}
if (minify === false) {
exportCommand.push('--no-minify');
}
if (dumpAssetmap) {
exportCommand.push('--dump-assetmap');
}
if (ssg === false) {
exportCommand.push('--no-ssg');
}
if (apiOnly) {
exportCommand.push('--api-only');
}
return exportCommand;
}
@@ -6,6 +6,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { templateString } from '@expo/template-file';
import assert from 'assert';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -36,6 +37,10 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -55,6 +60,9 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
export function generateGymfileFromTemplateFunction(): BuildFunction {
@@ -181,6 +189,7 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
ICLOUD_CONTAINER_ENVIRONMENT,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: './build',
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
...(PROFILES ? { PROFILES } : {}),
...(credentials
? {
@@ -27,7 +27,7 @@ export async function installNodeModules(
env: BuildStepEnv
): Promise<void> {
const { logger } = stepCtx;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
if (packagerRunDir !== stepCtx.workingDirectory) {
@@ -17,6 +17,9 @@ export interface MaestroFlowResult {
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
@@ -239,9 +242,6 @@ export async function parseMaestroResults(
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
@@ -322,3 +322,41 @@ async function relativizePathAsync(flowFilePath: string, projectRoot: string): P
}
return relative;
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* After the maestro retry loop completes, this produces a single canonical
* JUnit report at final_report_path matching the bash step's "cp latest
* attempt" semantics.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
export async function copyLatestAttemptXml(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`No *.xml files found in ${args.sourceDir}`);
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins.
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
return m ? parseInt(m[1], 10) : 0;
})();
for (let i = 1; i < xmlFiles.length; i++) {
const candidate = xmlFiles[i];
const match = candidate.match(ATTEMPT_PATTERN);
const attempt = match ? parseInt(match[1], 10) : 0;
if (attempt >= winnerAttempt) {
winner = candidate;
winnerAttempt = attempt;
}
}
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
}
@@ -0,0 +1,257 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { copyLatestAttemptXml } from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
function parseInput<S extends z.ZodTypeAny>(
schema: S,
value: unknown,
message: string
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
}
return result.data;
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
shards: number | undefined;
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'retries',
required: false,
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'include_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'exclude_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_format',
required: false,
defaultValue: 'junit',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'platform',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger, global } = stepCtx;
const platformInput = inputs.platform.value as string | undefined;
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
const includeTags = inputs.include_tags.value as string | undefined;
const excludeTags = inputs.exclude_tags.value as string | undefined;
const platform: 'ios' | 'android' =
platformInput === 'ios' || platformInput === 'android'
? platformInput
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
? 'ios'
: 'android';
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
// this env and writes debug output under $HOME/.maestro/tests; the step
// must read from the same place or stale files leak across runs.
const home = env.HOME;
if (!home) {
throw new SystemError('HOME env var is not set');
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
'flow_path must be a non-empty array of non-empty strings.'
);
const retries = parseInput(
RetriesSchema,
inputs.retries.value,
'retries must be a non-negative integer.'
);
const shards = parseInput(
ShardsSchema,
inputs.shards.value,
'shards must be a positive integer.'
);
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
} catch (err) {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
let lastAttemptExitCode: number | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
try {
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowPaths,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
}
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
logger.info('Test failed, retrying...');
await sleepAsync(2000);
}
// Copy the latest attempt's JUnit to the final report path so downstream
// upload/report steps have a single canonical file.
if (finalReportPath !== undefined) {
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure usually means maestro itself failed early
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
);
}
},
});
}
@@ -0,0 +1,51 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
export function parseXcactivitylogFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'parse_xcactivitylog',
name: 'Analyze build performance',
__metricsId: 'eas/parse_xcactivitylog',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'derived_data_path',
required: false,
defaultValue: 'ios/build',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'workspace_path',
required: false,
defaultValue: 'ios',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'xclogparser_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, env }) => {
const derivedDataPath = inputs.derived_data_path.value as string;
const workspacePath = inputs.workspace_path.value as string;
const version = inputs.xclogparser_version.value as string | undefined;
await parseAndReportXcactivitylog({
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
},
});
}
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
fn: async (stepCtx, { inputs, env }) => {
const { logger } = stepCtx;
const appleTeamId = inputs.apple_team_id.value as string | undefined;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
const job = stepCtx.global.staticContext.job;
@@ -0,0 +1,43 @@
import { Metadata } from '@expo/eas-build-job';
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAppConfig } from '../../utils/appConfig';
export function createReadAppConfigBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_app_config',
name: 'Read app config',
__metricsId: 'eas/read_app_config',
outputProviders: [
BuildStepOutput.createProvider({
id: 'app_config',
required: false,
}),
],
fn: async (stepCtx, { env, outputs }) => {
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
stepCtx.logger.info('Using app configuration:');
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
outputs.app_config.set(JSON.stringify(appConfig));
} catch {
// readAppConfig logs underlying failures.
}
},
});
}
@@ -0,0 +1,26 @@
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAndLogPackageJson } from '../../utils/project';
export function createReadPackageJsonBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_package_json',
name: 'Read package.json',
__metricsId: 'eas/read_package_json',
outputProviders: [
BuildStepOutput.createProvider({
id: 'package_json',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
try {
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
outputs.package_json.set(JSON.stringify(packageJson));
} catch (err: unknown) {
stepCtx.logger.error({ err });
}
},
});
}
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -0,0 +1,267 @@
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sleepAsync } from '../../utils/retry';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger }, { inputs, env }) => {
const packageVersion = inputs.package_version.value as string | undefined;
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function ensureCloudflaredInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}
async function ensureBunInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}
@@ -1,5 +1,11 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
@@ -253,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
},
});
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<void> {
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
if (!isAndroidEmulatorHostSupported) {
throw new UserError(
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
'Could not start the Android emulator on this runner.\n\n' +
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
' runs_on: linux-medium-nested-virtualization\n' +
' runs_on: linux-large-nested-virtualization\n\n' +
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
);
}
}
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
try {
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
return true;
} catch {
return false;
}
}
@@ -0,0 +1,504 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
jest.mock('@expo/downloader');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
schema: { name: 'TestProject' },
subSteps: [
{
title: 'Build target ModuleA',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 103.0,
signature: 'CompileC foo.c',
subSteps: [],
},
{
detailStepType: 'other',
signature: 'SwiftCompile normal bar.swift',
duration: 3.0,
startTimestamp: 101.0,
endTimestamp: 104.0,
subSteps: [],
},
],
},
{
title: 'Build target ModuleB',
subSteps: [
{
detailStepType: 'compileAssetsCatalog',
duration: 2.0,
startTimestamp: 200.0,
endTimestamp: 202.0,
signature: 'CompileAssetCatalog',
subSteps: [],
},
],
},
{
title: 'Build target TinyModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 0.1,
startTimestamp: 300.0,
endTimestamp: 300.1,
signature: 'CompileC tiny.c',
subSteps: [],
},
],
},
],
};
// Fixture: empty data
const FIXTURE_EMPTY = {
schema: { name: 'EmptyProject' },
subSteps: [],
};
// Fixture: no compile steps (only non-compile sub-steps)
const FIXTURE_NO_COMPILE = {
schema: { name: 'NoCompileProject' },
subSteps: [
{
title: 'Build target SomeTarget',
subSteps: [
{
detailStepType: 'linkCommand',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 105.0,
signature: 'Ld something',
subSteps: [],
},
],
},
],
};
// Fixture: missing timestamps
const FIXTURE_MISSING_TIMESTAMPS = {
schema: { name: 'MissingTimestamps' },
subSteps: [
{
title: 'Build target PartialModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 3.0,
signature: 'CompileC partial.c',
subSteps: [],
// no startTimestamp/endTimestamp
},
],
},
],
};
// Fixture: nested compile steps (compile step inside non-compile step)
const FIXTURE_NESTED = {
schema: { name: 'NestedProject' },
subSteps: [
{
title: 'Build target NestedModule',
subSteps: [
{
detailStepType: 'scriptExecution',
duration: 10.0,
startTimestamp: 100.0,
endTimestamp: 110.0,
signature: 'PhaseScriptExecution',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 2.0,
startTimestamp: 101.0,
endTimestamp: 103.0,
signature: 'CompileC nested.c',
subSteps: [],
},
],
},
],
},
],
};
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
}: {
tempDir?: string;
jsonOutput?: string;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
reportPath: path.join(tempDir, 'xcactivitylog.json'),
tempDir,
};
}
describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
schema: { name: 'BadProject' },
subSteps: [{ title: 123, subSteps: [] }],
}),
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('swallows cleanup failures without emitting additional logs', async () => {
const logger = createMockLogger();
mockFilesystem();
jest.spyOn(fs, 'rm').mockImplementation(async () => {
throw new Error('cleanup failed');
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
describe('isCompileStep', () => {
it('identifies cCompilation', () => {
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
});
it('identifies compileAssetsCatalog', () => {
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
});
it('identifies compileStoryboard', () => {
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
});
it('identifies SwiftCompile by signature prefix', () => {
expect(
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
).toBe(true);
});
it('identifies SwiftGeneratePch by signature prefix', () => {
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
true
);
});
it('rejects non-compile steps', () => {
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
});
it('handles missing fields gracefully', () => {
expect(isCompileStep({})).toBe(false);
});
});
describe('collectTopLevelCompileSteps', () => {
it('collects direct compile children', () => {
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(2);
});
it('finds nested compile steps through non-compile parents', () => {
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(1);
expect(result[0].detailStepType).toBe('cCompilation');
});
it('returns empty array when no compile steps', () => {
const target = FIXTURE_NO_COMPILE.subSteps[0];
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(0);
});
});
describe('buildTargetMetrics', () => {
it('returns metrics sorted by taskSeconds descending', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
expect(results.length).toBeGreaterThan(0);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
}
});
it('computes correct taskSeconds for ModuleA', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
});
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
});
it('filters out modules below minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
});
it('allows custom minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeDefined();
});
it('returns empty results for empty data', () => {
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
expect(results).toHaveLength(0);
expect(totalTaskSeconds).toBe(0);
});
it('handles missing timestamps gracefully', () => {
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
const mod = results.find(r => r.moduleName === 'PartialModule');
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(3.0);
expect(mod!.wallSpan).toBe(0); // no valid intervals
});
});
describe('formatReport', () => {
it('produces table with header and module rows', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('ModuleA');
expect(report).toContain('ModuleB');
expect(report).toContain('TOTAL');
expect(report).toContain('% Task');
});
it('does not include modules below threshold', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).not.toContain('TinyModule');
});
it('handles empty data without crashing', () => {
const report = formatReport(FIXTURE_EMPTY);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('TOTAL');
});
});
@@ -0,0 +1,418 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
}): Promise<void> {
let tempDir: string | undefined;
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
});
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
}
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
const proxiedUrl = getProxiedDownloadUrl({ directUrl, proxyBaseUrl });
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
{ err: proxiedDownloadResult.reason },
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
await cleanupXclogparserArtifacts({ tempDir, zipPath });
} else {
return proxiedDownloadResult.value;
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
return binaryPath;
}
async function cleanupXclogparserArtifacts({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<void> {
await asyncResult(fs.rm(zipPath, { force: true }));
await asyncResult(fs.rm(path.join(tempDir, 'xclogparser'), { force: true }));
}
async function runXclogparser({
binaryPath,
derivedDataPath,
workspacePath,
outputDir,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
await spawn(
binaryPath,
[
'parse',
'--derived_data',
derivedDataPath,
'--workspace',
workspacePath,
'--reporter',
'json',
'--output',
outputPath,
],
{ stdio: 'pipe' }
);
return outputPath;
}
const XcactivitylogStepSchemaZ = z.object({
title: z.string().optional(),
detailStepType: z.string().optional(),
signature: z.string().optional(),
duration: z.number().optional(),
startTimestamp: z.number().optional(),
endTimestamp: z.number().optional(),
get subSteps() {
return z.array(XcactivitylogStepSchemaZ).optional();
},
});
const XcactivitylogDataSchemaZ = z.object({
schema: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
subSteps: z.array(XcactivitylogStepSchemaZ).optional(),
});
type XcactivitylogStep = z.infer<typeof XcactivitylogStepSchemaZ>;
type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
interface TargetMetric {
moduleName: string;
taskSeconds: number;
wallSpan: number;
activeWallTime: number;
}
interface Interval {
start: number;
end: number;
}
const COMPILE_DETAIL_TYPES = new Set(['cCompilation', 'compileAssetsCatalog', 'compileStoryboard']);
const COMPILE_SIGNATURE_PREFIXES = ['SwiftCompile ', 'SwiftGeneratePch '];
function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
return true;
}
return COMPILE_SIGNATURE_PREFIXES.some(prefix => signature.startsWith(prefix));
}
export function collectTopLevelCompileSteps(
step: XcactivitylogStep,
results: XcactivitylogStep[] = []
): XcactivitylogStep[] {
for (const child of step.subSteps ?? []) {
if (isCompileStep(child)) {
results.push(child);
continue;
}
collectTopLevelCompileSteps(child, results);
}
return results;
}
function intervalFromStep(step: XcactivitylogStep): Interval | null {
if (typeof step.startTimestamp !== 'number' || typeof step.endTimestamp !== 'number') {
return null;
}
return { start: step.startTimestamp, end: step.endTimestamp };
}
function computeActiveWallTime(intervals: Interval[]): number {
if (intervals.length === 0) {
return 0;
}
const sorted = [...intervals].sort((a, b) => a.start - b.start);
let activeWallTime = 0;
let currentStart = sorted[0].start;
let currentEnd = sorted[0].end;
for (const interval of sorted.slice(1)) {
if (interval.start <= currentEnd) {
currentEnd = Math.max(currentEnd, interval.end);
continue;
}
activeWallTime += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
activeWallTime += currentEnd - currentStart;
return activeWallTime;
}
export function buildTargetMetrics(
data: XcactivitylogData,
{ minTaskSeconds = 0.5 }: { minTaskSeconds?: number } = {}
): { results: TargetMetric[]; totalTaskSeconds: number } {
const results: TargetMetric[] = [];
for (const step of data.subSteps ?? []) {
const title = step.title ?? '';
if (!title.startsWith('Build target ')) {
continue;
}
const moduleName = title.replace('Build target ', '');
const compileSteps = collectTopLevelCompileSteps(step);
const taskSeconds = compileSteps.reduce((sum, s) => sum + (s.duration ?? 0), 0);
if (taskSeconds < minTaskSeconds) {
continue;
}
const intervals = compileSteps.map(intervalFromStep).filter((i): i is Interval => i !== null);
const minStart = intervals.length > 0 ? Math.min(...intervals.map(i => i.start)) : 0;
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map(i => i.end)) : 0;
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
const activeWallTime = computeActiveWallTime(intervals);
results.push({ moduleName, taskSeconds, wallSpan, activeWallTime });
}
results.sort((a, b) => {
if (b.taskSeconds !== a.taskSeconds) {
return b.taskSeconds - a.taskSeconds;
}
if (b.wallSpan !== a.wallSpan) {
return b.wallSpan - a.wallSpan;
}
return a.moduleName.localeCompare(b.moduleName);
});
const totalTaskSeconds = results.reduce((sum, r) => sum + r.taskSeconds, 0);
return { results, totalTaskSeconds };
}
function formatSeconds(value: number): string {
return `${value.toFixed(1)}s`;
}
export function formatReport(data: XcactivitylogData): string {
const { results, totalTaskSeconds } = buildTargetMetrics(data);
const nameWidth = Math.max(6, ...results.map(r => r.moduleName.length)) + 2;
const taskWidth = 10;
const pctWidth = 8;
const wallWidth = 10;
const barMaxWidth = 20;
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const lines: string[] = [];
lines.push('');
lines.push('Xcode Build — Compile Metrics by Module');
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
lines.push('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Task'.padStart(taskWidth) +
' │ ' +
'% Task'.padStart(pctWidth) +
' │ ' +
'Wall'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const result of results) {
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
lines.push(
'│ ' +
result.moduleName.padEnd(nameWidth) +
' │ ' +
formatSeconds(result.taskSeconds).padStart(taskWidth) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.wallSpan).padStart(wallWidth) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -20,5 +20,9 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -13,4 +13,7 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
@@ -0,0 +1,139 @@
import { EasCliNpmTags } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
import { isAtLeastNpm7Async } from '../packageManager';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
isAtLeastNpm7Async: jest.fn(async () => true),
}));
describe(resolveEasCommandPrefixAndEnvAsync, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('resolves easd in development when easd --help succeeds', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 0 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'easd',
args: [],
extraEnv: {},
});
expect(spawn).toHaveBeenCalledWith(
'easd',
['--help'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
});
it('resolves staging tag in development when easd --help fails', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag in development when easd probe throws', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
process.env.ENVIRONMENT = 'staging';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
it('resolves production tag by default', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
expect(spawn).not.toHaveBeenCalled();
});
it('omits -y when npm is older than v7', async () => {
jest.mocked(isAtLeastNpm7Async).mockResolvedValueOnce(false);
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
});
});
describe(runEasCliCommand, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('merges caller env with resolved extra env', async () => {
process.env.ENVIRONMENT = 'staging';
await runEasCliCommand({
args: ['deploy', '--json'],
options: {
cwd: '/tmp/project',
env: { FOO: 'bar' },
},
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
expect.objectContaining({
cwd: '/tmp/project',
env: expect.objectContaining({
FOO: 'bar',
EXPO_STAGING: '1',
}),
})
);
});
});
@@ -1,3 +1,4 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
@@ -5,6 +6,7 @@ import path from 'path';
import semver from 'semver';
import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
@@ -23,23 +25,23 @@ describe(resolvePackageManager, () => {
});
it('returns yarn when no lockfiles exist', async () => {
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns npm when only package-json.lock exist', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir)).toBe('npm');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
});
it('returns yarn when only yarn.lock exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns yarn when both lockfiles exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns npm within a monorepo', async () => {
@@ -55,7 +57,7 @@ describe(resolvePackageManager, () => {
name: '@monorepo/expo-app',
});
expect(resolvePackageManager(nestedDir)).toBe('npm');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
});
it('returns yarn with an invalid monorepo', async () => {
@@ -67,7 +69,51 @@ describe(resolvePackageManager, () => {
await fs.mkdirp(nestedDir);
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
expect(resolvePackageManager(nestedDir)).toBe('yarn');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
});
it('returns yarn when no lockfile and env var is an empty string', () => {
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: '' },
})
).toBe(PackageManager.YARN);
});
it('returns bun from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bun' } })).toBe(
PackageManager.BUN
);
});
it('returns pnpm from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'pnpm' } })).toBe(
PackageManager.PNPM
);
});
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' }, // invalid, but should not even be read
})
).toBe(PackageManager.NPM);
});
it('throws a UserError on unsupported EAS_FALLBACK_PACKAGE_MANAGER value', () => {
expect(() =>
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } })
).toThrow(errors.UserError);
try {
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } });
} catch (e) {
expect(e).toBeInstanceOf(errors.UserError);
const error = e as errors.UserError;
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
expect(error.message).toContain('bunn');
expect(error.message).toContain('yarn, npm, pnpm, bun');
}
});
});
@@ -9,7 +9,12 @@ import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
import {
isUsingModernYarnVersion,
readAndLogPackageJson,
readEasJsonContents,
runExpoCliCommand,
} from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
@@ -148,3 +153,23 @@ describe(readEasJsonContents, () => {
expect(readEasJsonContents(projectDir)).toBe(contents);
});
});
describe(readAndLogPackageJson, () => {
beforeEach(() => {
vol.reset();
});
it('reads package.json, logs it, and returns parsed contents', async () => {
const projectDir = '/project';
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = { name: 'app', version: '1.0.0' };
const logger = { info: jest.fn() };
await fs.mkdirp(projectDir);
await fs.writeJson(packageJsonPath, packageJson);
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
});
});
+61
View File
@@ -0,0 +1,61 @@
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
import { isAtLeastNpm7Async } from './packageManager';
async function probeEasdAsync(): Promise<boolean> {
try {
const result = await spawn('easd', ['--help'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return result.status === 0;
} catch {
return false;
}
}
export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
if (await probeEasdAsync()) {
return { cmd: 'easd', args: [], extraEnv: {} };
}
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
export async function runEasCliCommand({
args,
options,
}: {
args: string[];
options: SpawnOptions;
}): Promise<SpawnResult> {
const { logger, ...spawnOptions } = options;
const { cmd, args: commandPrefixArgs, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
return await spawn(cmd, [...commandPrefixArgs, ...args], {
...spawnOptions,
logger,
env: { ...spawnOptions.env, ...extraEnv },
});
}
@@ -1,5 +1,7 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
import { z } from 'zod';
@@ -11,7 +13,10 @@ export enum PackageManager {
BUN = 'bun',
}
export function resolvePackageManager(directory: string): PackageManager {
export function resolvePackageManager(
directory: string,
{ env }: { env: BuildStepEnv }
): PackageManager {
try {
const manager = PackageManagerUtils.resolvePackageManager(directory);
if (manager === 'npm') {
@@ -20,12 +25,24 @@ export function resolvePackageManager(directory: string): PackageManager {
return PackageManager.PNPM;
} else if (manager === 'bun') {
return PackageManager.BUN;
} else {
} else if (manager === 'yarn') {
return PackageManager.YARN;
}
} catch {
return PackageManager.YARN;
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
}
return PackageManager.YARN;
}
/**
+10
View File
@@ -78,6 +78,16 @@ export function readPackageJson(projectDir: string): any {
}
}
export function readAndLogPackageJson(
logger: { info: (message: string) => void },
projectDir: string
): any {
logger.info('Using package.json:');
const packageJson = readPackageJson(projectDir);
logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
}
export function readEasJsonContents(projectDir: string): string {
const easJsonPath = path.join(projectDir, 'eas.json');
if (!fs.pathExistsSync(easJsonPath)) {
+1
View File
@@ -11,6 +11,7 @@
"composite": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["jest", "node"],
"plugins": [
{
"name": "gql.tada/ts-plugin",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.5.0",
"version": "18.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.5.0"
"@expo/steps": "18.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.5.0",
"version": "18.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -56,6 +56,110 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
@@ -84,6 +188,232 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context with app_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'ready_for_review',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect app_version state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with external_beta', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'beta_approved',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect external_beta state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with beta_feedback', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'screenshot',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects app_store_connect beta_feedback without url', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'crash',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect beta_feedback type', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'invalid-type',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
@@ -403,6 +403,7 @@ describe('Ios.JobSchema', () => {
);
expect(value).not.toMatchObject(managedJob);
});
test('validates channel', () => {
const managedJob = {
secrets: {
+68 -13
View File
@@ -226,6 +226,72 @@ const GitHubContextZ = z.object({
.optional(),
});
const AppStoreConnectContextZ = z.looseObject({
app: z.looseObject({
id: z.string(),
}),
app_version: z
.looseObject({
id: z.string(),
state: z.enum([
'accepted',
'developer_rejected',
'in_review',
'invalid_binary',
'metadata_rejected',
'pending_apple_release',
'pending_developer_release',
'prepare_for_submission',
'processing_for_distribution',
'ready_for_distribution',
'ready_for_review',
'rejected',
'replaced_with_new_version',
'waiting_for_export_compliance',
'waiting_for_review',
]),
})
.optional(),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
cf_bundle_version: z.string().optional(),
build: z
.looseObject({
id: z.string(),
})
.optional(),
})
.optional(),
external_beta: z
.looseObject({
id: z.string(),
state: z.enum([
'processing',
'processing_exception',
'missing_export_compliance',
'ready_for_beta_testing',
'in_beta_testing',
'expired',
'ready_for_beta_submission',
'in_export_compliance_review',
'waiting_for_beta_review',
'in_beta_review',
'beta_rejected',
'beta_approved',
]),
})
.optional(),
beta_feedback: z
.looseObject({
id: z.string(),
type: z.enum(['crash', 'screenshot']),
url: z.string(),
})
.optional(),
});
export const StaticWorkflowInterpolationContextZ = z.object({
after: z.record(
z.string(),
@@ -259,19 +325,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
})
.optional(),
})
.optional(),
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
app_store_connect: AppStoreConnectContextZ.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+3
View File
@@ -41,6 +41,7 @@ export enum BuildPhase {
INSTALL_PODS = 'INSTALL_PODS',
CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT',
RUN_FASTLANE = 'RUN_FASTLANE',
PARSE_XCACTIVITYLOG = 'PARSE_XCACTIVITYLOG',
// HOOKS
PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK',
@@ -108,6 +109,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'Install pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project',
[BuildPhase.RUN_FASTLANE]: 'Run fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'Analyze build performance',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook',
@@ -172,6 +174,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'install-pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project',
[BuildPhase.RUN_FASTLANE]: 'run-fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'parse-xcactivitylog',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook',
+1 -1
View File
@@ -50,7 +50,7 @@ yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
The GraphQL schema is fetched from the API URL resolved by `src/api.ts` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import { getExpoApiBaseUrl } from './src/api';
const schema =
process.env.GRAPHQL_SCHEMA_URL ?? new URL('/graphql', getExpoApiBaseUrl()).toString();
const config: CodegenConfig = {
overwrite: true,
schema,
documents: ['src/**/!(*.d).ts'],
generates: {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
dedupeOperationSuffix: true,
},
hooks: {
afterOneFileWrite: ['node ./scripts/annotate-graphql-codegen.js'],
},
},
'./graphql.schema.json': {
plugins: ['introspection'],
},
},
};
export default config;
-17
View File
@@ -1,17 +0,0 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
./graphql.schema.json:
plugins:
- 'introspection'
+5698 -532
View File
File diff suppressed because it is too large. Load diff
+11 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.5.0",
"version": "18.10.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -37,7 +37,7 @@
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.5.0",
"@expo/eas-json": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.0",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.5.0",
"@expo/steps": "18.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -201,9 +201,15 @@
"env": {
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage third-party service integrations"
},
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
-67
View File
@@ -800,65 +800,6 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePricing": {
"type": "object",
"additionalProperties": false,
"description": "App pricing configuration. NOTE: Apple migrated to base-territory pricing in 2023. EAS metadata currently uses the legacy `appPriceTier` API exposed by @expo/apple-utils.",
"properties": {
"tier": {
"type": "string",
"description": "App Store price tier id (e.g. \"0\" for free, \"1\" for tier 1).",
"defaultSnippets": [
{ "label": "Free", "body": "0" },
{ "label": "Tier 1", "body": "1" }
]
},
"schedule": {
"type": "array",
"description": "Future scheduled price changes. Each entry switches the app to the given tier on `startDate`. Currently parsed but not pushed (the underlying appPriceSchedules endpoint is not yet wrapped by @expo/apple-utils).",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["startDate", "tier"],
"properties": {
"startDate": {
"type": "string",
"description": "ISO-8601 date the new price tier takes effect.",
"format": "date-time"
},
"tier": {
"type": "string",
"description": "App Store price tier id (e.g. \"0\" for free)."
}
}
}
}
}
},
"AppleAvailability": {
"type": "object",
"additionalProperties": false,
"description": "Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g. \"USA\", \"GBR\", \"JPN\") which match App Store Connect Territory ids.",
"properties": {
"territories": {
"description": "Territories the app is available in. Use the literal string \"all\" to make the app available worldwide.",
"oneOf": [
{
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"description": "ISO-3166 alpha-3 territory code (e.g. \"USA\")."
}
},
{
"type": "string",
"enum": ["all"]
}
]
}
}
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1093,14 +1034,6 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"pricing": {
"$ref": "#/definitions/apple/ApplePricing",
"description": "App pricing configuration (price tier + scheduled changes)."
},
"availability": {
"$ref": "#/definitions/apple/AppleAvailability",
"description": "Territory availability configuration."
}
}
}
+346
View File
@@ -0,0 +1,346 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
/*
* Manual Convex integration E2E runner.
*
* Run from packages/eas-cli with:
* yarn ts-node scripts/convex-e2e.ts
*
* This uses the deployed EAS API and real authenticated EAS/Convex-backed
* resources. Cleanup removes EAS-side links when possible, but it does not
* destroy Convex-side resources.
*
* Options:
* CONVEX_E2E_KEEP_PROJECT=1 keeps the temp Expo project and skips cleanup.
* CONVEX_E2E_SKIP_DASHBOARD=1 skips opening the Convex dashboard.
* CONVEX_E2E_ACCOUNT=<account-name> sets the Expo app owner before eas init.
*/
type RunOptions = {
cwd?: string;
allowFailure?: boolean;
env?: NodeJS.ProcessEnv;
};
type RunResult = {
stdout: string;
stderr: string;
exitCode: number | null;
};
const KEEP_PROJECT = process.env.CONVEX_E2E_KEEP_PROJECT === '1';
const SKIP_DASHBOARD = process.env.CONVEX_E2E_SKIP_DASHBOARD === '1';
const ACCOUNT = process.env.CONVEX_E2E_ACCOUNT;
const SCRIPT_DIR = __dirname;
const EAS_CLI_ROOT = path.resolve(SCRIPT_DIR, '..');
const LOCAL_EAS_BIN = path.join(EAS_CLI_ROOT, 'bin', 'run');
const TEMP_ROOT_PREFIX = path.join(os.tmpdir(), 'eas-cli-convex-e2e-');
let tempRoot: string | null = null;
let projectDir: string | null = null;
let initializedProject = false;
let initialConvexTeamSlugs: string[] = [];
let createdConvexTeamSlug: string | null = null;
async function mainAsync(): Promise<void> {
try {
await assertFileExistsAsync(
LOCAL_EAS_BIN,
`Local EAS CLI binary not found at ${LOCAL_EAS_BIN}`
);
await runEasAsync(['whoami']);
const testName = `convex-e2e-${Date.now().toString(36)}`;
tempRoot = await fs.mkdtemp(TEMP_ROOT_PREFIX);
projectDir = path.join(tempRoot, testName);
logStep(`Creating fresh Expo project ${testName}`);
await runAsync('npx', ['--yes', 'create-expo-app@latest', projectDir, '--template', 'blank'], {
cwd: tempRoot,
env: getNpxEnvironment(),
});
await updateAppJsonAsync(projectDir, testName);
logStep('Creating/linking EAS project');
await runEasAsync(['init', '--non-interactive', '--force'], projectDir);
initializedProject = true;
initialConvexTeamSlugs = parseConvexTeamSlugs(
(await runEasAsync(['integrations:convex:team'], projectDir)).stdout
);
logStep('Connecting Convex');
await runEasAsync(
['integrations:convex:connect', '--non-interactive', '--project-name', testName],
projectDir
);
await assertEnvLocalAsync(projectDir);
await assertPackageJsonDependencyAsync(projectDir, 'convex');
const teamOutput = await runEasAsync(['integrations:convex:team'], projectDir);
const projectOutput = await runEasAsync(['integrations:convex:project'], projectDir);
assertIncludes(
projectOutput.stdout,
'Dashboard',
'Expected project output to include dashboard'
);
const teamSlugsAfterConnect = parseConvexTeamSlugs(teamOutput.stdout);
const newTeamSlugs = teamSlugsAfterConnect.filter(
slug => !initialConvexTeamSlugs.includes(slug)
);
createdConvexTeamSlug = newTeamSlugs[0] ?? null;
const convexTeamSlug = createdConvexTeamSlug ?? teamSlugsAfterConnect[0];
if (!convexTeamSlug) {
throw new Error('Could not determine a Convex team slug from team output.');
}
await runEasAsync(
['integrations:convex:team:invite', convexTeamSlug, '--non-interactive'],
projectDir
);
if (SKIP_DASHBOARD) {
logStep('Skipping dashboard command because CONVEX_E2E_SKIP_DASHBOARD=1');
} else {
await runEasAsync(['integrations:convex:dashboard'], projectDir);
}
logStep('Convex E2E flow completed successfully');
} finally {
await cleanupAsync();
}
}
async function cleanupAsync(): Promise<void> {
if (!projectDir || !tempRoot) {
return;
}
if (KEEP_PROJECT) {
logStep('Skipping cleanup because CONVEX_E2E_KEEP_PROJECT=1');
log(`Temp project kept at: ${projectDir}`);
log('EAS-side links and Convex-side resources were left intact.');
return;
}
if (initializedProject) {
logStep('Cleaning up EAS-side Convex links');
const convexTeamSlugToDelete =
createdConvexTeamSlug ?? (await getCreatedConvexTeamSlugForCleanupAsync());
await runCleanupCommandAsync([
'integrations:convex:project:delete',
'--non-interactive',
'--yes',
]);
if (convexTeamSlugToDelete) {
await runCleanupCommandAsync([
'integrations:convex:team:delete',
convexTeamSlugToDelete,
'--non-interactive',
'--yes',
]);
} else {
warn(
'No newly-created Convex team link was detected. Skipping team link deletion to avoid removing a pre-existing EAS-side link.'
);
}
warn('Convex-side resources are not destroyed by this cleanup.');
}
await fs.rm(tempRoot, { recursive: true, force: true });
log(`Removed temp project directory: ${tempRoot}`);
}
async function getCreatedConvexTeamSlugForCleanupAsync(): Promise<string | null> {
if (!projectDir) {
return null;
}
const result = await runEasAsync(['integrations:convex:team'], projectDir, {
allowFailure: true,
});
if (result.exitCode !== 0) {
warn('Could not inspect Convex team links during cleanup.');
return null;
}
return (
parseConvexTeamSlugs(result.stdout).find(slug => !initialConvexTeamSlugs.includes(slug)) ?? null
);
}
async function runCleanupCommandAsync(args: string[]): Promise<void> {
try {
const result = await runEasAsync(args, projectDir ?? undefined, { allowFailure: true });
if (result.exitCode !== 0) {
warn(`Cleanup command exited with ${result.exitCode}: ${args.join(' ')}`);
}
} catch (error) {
warn(`Cleanup command failed: ${args.join(' ')}`);
warn(error instanceof Error ? error.message : String(error));
}
}
async function runEasAsync(
args: string[],
cwd?: string,
options: RunOptions = {}
): Promise<RunResult> {
return await runAsync(LOCAL_EAS_BIN, args, { cwd, ...options });
}
async function runAsync(
command: string,
args: string[],
{ cwd, allowFailure = false, env = {} }: RunOptions = {}
): Promise<RunResult> {
const printableCommand = [command, ...args].join(' ');
log(`\n$ ${printableCommand}`);
if (cwd) {
log(`cwd: ${cwd}`);
}
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
const chunk = data.toString();
stdout += chunk;
process.stdout.write(redact(chunk));
});
child.stderr.on('data', data => {
const chunk = data.toString();
stderr += chunk;
process.stderr.write(redact(chunk));
});
child.on('error', reject);
child.on('close', exitCode => {
const result = { stdout, stderr, exitCode };
if (!allowFailure && exitCode !== 0) {
reject(new Error(`Command failed with exit code ${exitCode}: ${printableCommand}`));
return;
}
resolve(result);
});
});
}
async function updateAppJsonAsync(appDir: string, testName: string): Promise<void> {
const appJsonPath = path.join(appDir, 'app.json');
const rawAppJson = await fs.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(rawAppJson);
appJson.expo = {
...appJson.expo,
name: testName,
slug: testName,
...(ACCOUNT ? { owner: ACCOUNT } : {}),
};
await fs.writeFile(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`);
}
async function assertEnvLocalAsync(appDir: string): Promise<void> {
const envLocalPath = path.join(appDir, '.env.local');
const envLocal = await fs.readFile(envLocalPath, 'utf8');
assertIncludes(
envLocal,
'CONVEX_DEPLOY_KEY=',
'Expected .env.local to contain CONVEX_DEPLOY_KEY'
);
assertIncludes(
envLocal,
'EXPO_PUBLIC_CONVEX_URL=',
'Expected .env.local to contain EXPO_PUBLIC_CONVEX_URL'
);
log('\nVerified .env.local contains Convex environment variables:');
log(redact(envLocal));
}
async function assertPackageJsonDependencyAsync(
appDir: string,
packageName: string
): Promise<void> {
const packageJsonPath = path.join(appDir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (!dependencies[packageName]) {
throw new Error(`Expected package.json to include ${packageName}`);
}
log(`Verified package.json includes ${packageName}@${dependencies[packageName]}`);
}
function parseConvexTeamSlugs(output: string): string[] {
const strippedOutput = stripAnsi(output);
return Array.from(strippedOutput.matchAll(/^Team:\s+.+?\s\/\s(.+)$/gm), match => match[1].trim());
}
function assertIncludes(value: string, expected: string, message: string): void {
if (!value.includes(expected)) {
throw new Error(message);
}
}
async function assertFileExistsAsync(filePath: string, message: string): Promise<void> {
try {
await fs.access(filePath);
} catch {
throw new Error(message);
}
}
function logStep(message: string): void {
log(`\n==> ${message}`);
}
function log(message: string): void {
process.stdout.write(`${message}\n`);
}
function warn(message: string): void {
process.stderr.write(`${message}\n`);
}
function redact(value: string): string {
return value
.replace(/^CONVEX_DEPLOY_KEY=.*$/gm, 'CONVEX_DEPLOY_KEY=<redacted>')
.replace(/dev:[^\s|]+\|[^\s]+/g, 'dev:<redacted>');
}
function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-9;]*m/g, '');
}
function getNpxEnvironment(): NodeJS.ProcessEnv {
return {
npm_config_user_agent: `npm/10 node/${process.versions.node} ${process.platform} ${process.arch}`,
npm_execpath: undefined,
};
}
mainAsync().catch(error => {
warn(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -0,0 +1,75 @@
import fs from 'fs-extra';
import path from 'path';
import * as tar from 'tar';
import { Client } from '../../../vcs/vcs';
import { makeProjectTarballAsync } from '../repository';
jest.mock('../../../ora', () => ({
ora: jest.fn(() => ({
fail: jest.fn(),
isSpinning: false,
start: jest.fn(),
succeed: jest.fn(),
})),
}));
class FakeVcsClient extends Client {
public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
await fs.mkdirp(path.join(destinationPath, 'bin'));
await fs.mkdirp(path.join(destinationPath, 'read-only-dir'));
await fs.writeFile(path.join(destinationPath, 'bin', 'postcheckout.sh'), '#!/bin/sh\necho hi\n');
await fs.writeFile(path.join(destinationPath, 'regular-file.txt'), 'regular file\n');
await fs.writeFile(path.join(destinationPath, 'read-only-dir', 'child.txt'), 'child file\n');
await fs.chmod(path.join(destinationPath, 'bin', 'postcheckout.sh'), 0o755);
await fs.chmod(path.join(destinationPath, 'regular-file.txt'), 0o644);
await fs.chmod(path.join(destinationPath, 'read-only-dir'), 0o555);
}
public async getRootPathAsync(): Promise<string> {
return process.cwd();
}
public canGetLastCommitMessage(): boolean {
return false;
}
}
describe(makeProjectTarballAsync, () => {
it('creates portable project archives while preserving executable files', async () => {
const removeAsync = fs.remove.bind(fs);
const removeSpy = jest.spyOn(fs, 'remove').mockImplementation(async targetPath => {
await fs.chmod(path.join(targetPath.toString(), 'read-only-dir'), 0o755).catch(() => {});
await removeAsync(targetPath);
});
const projectTarball = await makeProjectTarballAsync(new FakeVcsClient());
const entries = new Map<string, tar.ReadEntry>();
try {
await tar.list({
file: projectTarball.path,
onentry: entry => {
entries.set(entry.path, entry);
},
});
expect(entries.get('project/bin/postcheckout.sh')?.mode).toBe(0o755);
expect(entries.get('project/regular-file.txt')?.mode).toBe(0o644);
expect(entries.get('project/read-only-dir/')?.mode).toBe(0o755);
expect(entries.has('project/read-only-dir/child.txt')).toBe(true);
for (const entry of entries.values()) {
expect(entry.path.startsWith('project/')).toBe(true);
expect(entry.uid).toBeUndefined();
expect(entry.gid).toBeUndefined();
expect(entry.uname).toBe('');
expect(entry.gname).toBe('');
}
} finally {
await fs.remove(projectTarball.path);
removeSpy.mockRestore();
}
});
});
+10 -3
View File
@@ -143,9 +143,16 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
try {
await vcsClient.makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
await tar.create(
{
cwd: shallowClonePath,
file: tarPath,
prefix: 'project',
gzip: true,
portable: true,
},
['.']
);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
+18
View File
@@ -64,6 +64,24 @@ export function getWorkflowRunUrl(
).toString();
}
/**
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
* or the feature-specific dashboard) that gives users more context. Use this only for internal
* tooling where no richer URL exists.
*/
export function getBareJobRunUrl(
accountName: string,
projectName: string,
jobRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/job-runs/${encodeURIComponent(jobRunId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
@@ -0,0 +1,142 @@
import { ChannelRuntimeInsights } from '../../../graphql/queries/ChannelInsightsQuery';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../formatInsights';
function makeInsights(
overrides: {
embeddedUpdateTotalUniqueUsers?: number;
mostPopularUpdates?: {
id: string;
group: string;
message: string | null;
runtimeVersion: string;
platform: string;
totalUniqueUsers: number;
}[];
} = {}
): ChannelRuntimeInsights {
return {
__typename: 'UpdateChannelRuntimeInsights',
id: 'rti-id',
embeddedUpdateTotalUniqueUsers: overrides.embeddedUpdateTotalUniqueUsers ?? 250,
mostPopularUpdates: (
overrides.mostPopularUpdates ?? [
{
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
totalUniqueUsers: 800,
},
{
id: 'u2',
group: 'group-b',
message: 'second',
runtimeVersion: '1.0.0',
platform: 'android',
totalUniqueUsers: 200,
},
]
).map(u => ({
__typename: 'Update' as const,
id: u.id,
group: u.group,
message: u.message,
runtimeVersion: u.runtimeVersion,
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
id: `${u.id}-insights`,
totalUniqueUsers: u.totalUniqueUsers,
},
})),
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'd1', label: 'iOS', data: [100, 200] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'cd1', label: 'Launches', data: [10, 20] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint', id: 'launches', label: 'Launches', data: 1234 },
],
},
};
}
const TIMESPAN = {
startTime: '2026-04-09T00:00:00.000Z',
endTime: '2026-04-16T00:00:00.000Z',
daysBack: 7,
};
describe(toChannelInsightsSummary, () => {
it('ranks most popular updates by their order in the response', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.mostPopularUpdates.map(u => u.rank)).toEqual([1, 2]);
expect(summary.mostPopularUpdates[0].groupId).toBe('group-a');
});
it('sums OTA totalUniqueUsers across most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.otaTotalUniqueUsers).toBe(1000);
});
it('passes through embedded update unique users', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ embeddedUpdateTotalUniqueUsers: 42 }),
TIMESPAN
);
expect(summary.embeddedUpdateTotalUniqueUsers).toBe(42);
});
});
describe(buildChannelInsightsJson, () => {
it('returns a serializable object', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const json = buildChannelInsightsJson(summary) as any;
expect(json.channel).toBe('production');
expect(json.runtimeVersion).toBe('1.0.0');
expect(json.timespan.daysBack).toBe(7);
expect(json.mostPopularUpdates).toHaveLength(2);
expect(json.cumulativeMetricsAtLastTimestamp[0].label).toBe('Launches');
});
});
describe(buildChannelInsightsTable, () => {
it('renders channel, runtime, embedded users and most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/Channel/);
expect(table).toMatch(/production/);
expect(table).toMatch(/1\.0\.0/);
expect(table).toMatch(/Embedded update users/);
expect(table).toMatch(/group-a/);
expect(table).toMatch(/Most popular updates/);
});
it('shows a friendly message when there are no popular updates', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ mostPopularUpdates: [] }),
TIMESPAN
);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/No update launches recorded/);
});
});
@@ -0,0 +1,164 @@
import chalk from 'chalk';
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
import { formatTimespan } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
export interface ChannelInsightsTimespan {
startTime: string;
endTime: string;
daysBack?: number;
}
export interface ChannelMostPopularUpdate {
rank: number;
groupId: string;
message: string | null;
platform: string;
totalUniqueUsers: number;
}
export interface ChannelInsightsSummary {
channelName: string;
runtimeVersion: string;
startTime: string;
endTime: string;
daysBack?: number;
embeddedUpdateTotalUniqueUsers: number;
otaTotalUniqueUsers: number;
mostPopularUpdates: ChannelMostPopularUpdate[];
cumulativeMetricsAtLastTimestamp: { id: string; label: string; data: number }[];
uniqueUsersOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
cumulativeMetricsOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
}
export function toChannelInsightsSummary(
channelName: string,
runtimeVersion: string,
insights: ChannelRuntimeInsights,
timespan: ChannelInsightsTimespan
): ChannelInsightsSummary {
const mostPopular: ChannelMostPopularUpdate[] = insights.mostPopularUpdates.map((u, i) => ({
rank: i + 1,
groupId: u.group,
message: u.message ?? null,
platform: u.platform,
totalUniqueUsers: u.insights.totalUniqueUsers,
}));
const otaTotalUniqueUsers = mostPopular.reduce((sum, u) => sum + u.totalUniqueUsers, 0);
return {
channelName,
runtimeVersion,
startTime: timespan.startTime,
endTime: timespan.endTime,
daysBack: timespan.daysBack,
embeddedUpdateTotalUniqueUsers: insights.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers,
mostPopularUpdates: mostPopular,
cumulativeMetricsAtLastTimestamp: insights.cumulativeMetricsOverTime.metricsAtLastTimestamp.map(
m => ({ id: m.id, label: m.label, data: m.data })
),
uniqueUsersOverTime: {
labels: insights.uniqueUsersOverTime.data.labels,
datasets: insights.uniqueUsersOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
cumulativeMetricsOverTime: {
labels: insights.cumulativeMetricsOverTime.data.labels,
datasets: insights.cumulativeMetricsOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
};
}
export function buildChannelInsightsJson(summary: ChannelInsightsSummary): object {
return {
channel: summary.channelName,
runtimeVersion: summary.runtimeVersion,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
mostPopularUpdates: summary.mostPopularUpdates,
cumulativeMetricsAtLastTimestamp: summary.cumulativeMetricsAtLastTimestamp,
uniqueUsersOverTime: summary.uniqueUsersOverTime,
cumulativeMetricsOverTime: summary.cumulativeMetricsOverTime,
};
}
export function buildChannelInsightsTable(summary: ChannelInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Channel insights:'));
sections.push(
formatFields([
{ label: 'Channel', value: summary.channelName },
{ label: 'Runtime version', value: summary.runtimeVersion },
{ label: 'Time range', value: formatTimespan(summary) },
{
label: 'Embedded update users',
value: summary.embeddedUpdateTotalUniqueUsers.toLocaleString(),
},
{ label: 'OTA update users', value: summary.otaTotalUniqueUsers.toLocaleString() },
])
);
if (summary.cumulativeMetricsAtLastTimestamp.length > 0) {
sections.push('');
sections.push(chalk.bold('Cumulative metrics at last timestamp:'));
sections.push(
formatFields(
summary.cumulativeMetricsAtLastTimestamp.map(m => ({
label: m.label,
value: m.data.toLocaleString(),
}))
)
);
}
if (summary.mostPopularUpdates.length > 0) {
sections.push('');
sections.push(chalk.bold(`Most popular updates${formatTrailingTimespan(summary)}:`));
sections.push(renderMostPopularTable(summary.mostPopularUpdates));
} else {
sections.push('');
sections.push(chalk.yellow('No update launches recorded for this channel and runtime.'));
}
return sections.join('\n');
}
function formatTrailingTimespan(summary: ChannelInsightsSummary): string {
return summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
}
function renderMostPopularTable(rows: ChannelMostPopularUpdate[]): string {
return renderTextTable(
['#', 'Group ID', 'Platform', 'Unique users', 'Message'],
rows.map(r => [
String(r.rank),
r.groupId,
r.platform,
r.totalUniqueUsers.toLocaleString(),
r.message ?? '',
])
);
}
@@ -0,0 +1,126 @@
import {
confirmRecentConvexInviteAsync,
formatConvexProject,
formatConvexTeam,
formatConvexTeamConnection,
getConvexProjectDashboardUrl,
getConvexTeamDashboardUrl,
} from '../convex';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../graphql/types/ConvexTeamConnection';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
jest.mock('../../log');
jest.mock('../../prompts');
describe('Convex command utilities', () => {
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('builds encoded Convex dashboard URLs', () => {
expect(getConvexTeamDashboardUrl(mockConnection)).toBe(
'https://dashboard.convex.dev/t/test%20team'
);
expect(getConvexProjectDashboardUrl(mockProject)).toBe(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('formats team and project metadata with invite details', () => {
expect(formatConvexTeam(mockConnection)).toBe('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
expect(formatConvexTeamConnection(mockConnection)).toContain('[email protected]');
expect(formatConvexTeamConnection(mockConnection)).toContain('2024-01-02T00:00:00.000Z');
expect(formatConvexProject(mockProject)).toContain('project-123');
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
expect(formatConvexProject(mockProject)).not.toContain('team-123');
});
it('does not prompt when the previous invite timestamp is invalid', async () => {
await expect(
confirmRecentConvexInviteAsync(
{ ...mockConnection, invitedAt: 'not-a-date' },
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt when the previous invite is old', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts before resending a recent invite in interactive mode', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(false);
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
});
it('warns and proceeds for recent invites in non-interactive mode', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: true }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
});
});
@@ -0,0 +1,83 @@
import chalk from 'chalk';
import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
import Log, { link } from '../log';
import { confirmAsync } from '../prompts';
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;
export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
}
export function getConvexProjectDashboardUrl(project: ConvexProjectData): string {
return `${getConvexTeamDashboardUrl(project.convexTeamConnection)}/${encodeURIComponent(
project.convexProjectSlug
)}`;
}
export function formatConvexTeam(connection: ConvexTeamConnectionData): string {
return `${connection.convexTeamName} / ${connection.convexTeamSlug}`;
}
export function formatConvexTeamConnection(connection: ConvexTeamConnectionData): string {
const lines = [
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
];
if (connection.invitedEmail) {
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
lines.push(`${chalk.bold('Invited at')}: ${connection.invitedAt}`);
}
return lines.join('\n');
}
export function formatConvexProject(project: ConvexProjectData): string {
return [
`${chalk.bold('Name')}: ${project.convexProjectName}`,
`${chalk.bold('Slug')}: ${project.convexProjectSlug}`,
`${chalk.bold('Identifier')}: ${project.convexProjectIdentifier}`,
`${chalk.bold('Team')}: ${formatConvexTeam(project.convexTeamConnection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoConvexTeams(accountName: string): void {
Log.warn(`No Convex team is linked to account ${chalk.bold(accountName)} on EAS.`);
}
export function logNoConvexProject(projectName: string): void {
Log.warn(`No Convex project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export async function confirmRecentConvexInviteAsync(
connection: ConvexTeamConnectionData,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<boolean> {
const invitedAt = connection.invitedAt ? new Date(connection.invitedAt) : null;
if (!invitedAt || Number.isNaN(invitedAt.getTime())) {
return true;
}
const timeSinceInviteMs = Date.now() - invitedAt.getTime();
if (timeSinceInviteMs >= RECENT_INVITE_THRESHOLD_MS) {
return true;
}
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${connection.invitedAt}.`;
if (nonInteractive) {
Log.warn(
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
);
return true;
}
return await confirmAsync({
message: `${previousInvite} Are you sure you want to send another invite?`,
});
}
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
@@ -0,0 +1,207 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import Download from '../download';
jest.mock('../../../graphql/queries/BuildQuery');
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-123',
platform: AppPlatform.Ios,
project: { id: 'project-1' },
artifacts: {
applicationArchiveUrl: 'https://example.com/app.tar.gz',
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
},
...overrides,
} as unknown as BuildFragment;
}
describe(Download, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): Download {
const command = new Download(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
// Stub the file-system-touching helpers so we don't hit disk.
jest
.spyOn(command as any, 'getPathToBuildArtifactAsync')
.mockResolvedValue('/cache/path/to/build.app');
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
return command;
}
it('errors when neither --build-id nor --fingerprint is provided', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(
'Either --build-id or --fingerprint is required.'
);
});
it('fetches build by ID when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches build by ID when --id alias is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--id', 'build-123']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
await command.runAsync();
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
expect(mockByIdAsync).not.toHaveBeenCalled();
});
it('uses platform from the build itself when --build-id is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
const command = createCommand(['--build-id', 'build-123']);
await command.runAsync();
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
// The spy was set up after instantiation; just ensure runAsync used the build's platform
// by checking that no platform prompt was needed.
expect(getPathSpy).toBeDefined();
});
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow(
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
);
});
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: null,
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123']);
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
});
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
mockByIdAsync.mockResolvedValue(
makeBuild({
artifacts: {
applicationArchiveUrl: null,
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
xcodeBuildLogsUrl: null,
buildUrl: null,
} as any,
})
);
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
const downloadExtrasSpy = jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
await expect(command.runAsync()).resolves.not.toThrow();
expect(downloadExtrasSpy).toHaveBeenCalled();
});
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
const downloadExtrasSpyWithout = jest
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({});
await commandWithoutFlag.runAsync();
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
const commandWithFlag = createCommand([
'--fingerprint',
'fp-abc',
'--platform',
'ios',
'--all-artifacts',
]);
const downloadExtrasSpyWith = jest
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
await commandWithFlag.runAsync();
expect(downloadExtrasSpyWith).toHaveBeenCalled();
});
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand([
'--build-id',
'build-123',
'--all-artifacts',
'--json',
'--non-interactive',
]);
jest
.spyOn(command as any, 'downloadExtraArtifactsAsync')
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
path: '/cache/path/to/build.app',
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
});
});
});
+10 -2
View File
@@ -47,6 +47,10 @@ export default class BuildDev extends EasCommand {
description: 'Skip build if no successful build with matching fingerprint is found.',
default: false,
}),
'skip-bundler': Flags.boolean({
description: 'Install and run the development build without starting the bundler server.',
default: false,
}),
};
static override contextDefinition = {
@@ -129,7 +133,9 @@ export default class BuildDev extends EasCommand {
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build);
await this.startDevServerAsync({ projectDir, platform });
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
return;
} else {
Log.warn('Artifacts for this build expired.');
@@ -182,7 +188,9 @@ export default class BuildDev extends EasCommand {
downloadSimBuildAutoConfirm: true,
envOverride: env,
});
await this.startDevServerAsync({ projectDir, platform });
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
}
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
+165 -19
View File
@@ -1,7 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -15,24 +16,44 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getEasBuildRunCachedAppPath } from '../../run/run';
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
import {
downloadAndMaybeExtractAppAsync,
downloadFileWithProgressTrackerAsync,
} from '../../utils/download';
import { formatBytes } from '../../utils/files';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
export default class Download extends EasCommand {
static override description = 'download simulator/emulator builds for a given fingerprint hash';
static override description =
'download a simulator/emulator build by build ID or fingerprint hash';
static override flags = {
'build-id': Flags.string({
aliases: ['id'],
description:
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
exclusive: ['fingerprint', 'platform', 'dev-client'],
}),
fingerprint: Flags.string({
description: 'Fingerprint hash of the build to download',
required: true,
exclusive: ['build-id'],
}),
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
exclusive: ['build-id'],
})(),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
exclusive: ['build-id'],
}),
'all-artifacts': Flags.boolean({
description:
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -45,14 +66,20 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
'build-id': buildId,
platform,
fingerprint,
'dev-client': developmentClient,
'all-artifacts': allArtifacts,
...rawFlags
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
if (!buildId && !fingerprint) {
throw new Error('Either --build-id or --fingerprint is required.');
}
const {
loggedIn: { graphqlClient },
projectId,
@@ -64,24 +91,113 @@ export default class Download extends EasCommand {
enableJsonOutput();
}
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
const build = await this.getBuildAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint,
developmentClient,
});
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
let build: BuildFragment;
let selectedPlatform: AppPlatform;
if (buildId) {
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
selectedPlatform = build.platform;
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
} else {
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
build = await this.getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint!,
developmentClient,
});
}
let buildArtifactPath: string | null = null;
if (build.artifacts?.applicationArchiveUrl) {
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
} else if (!allArtifacts) {
const availableArtifacts = listAvailableExtraArtifactNames(build);
if (availableArtifacts.length > 0) {
throw new Error(
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
);
}
throw new Error('Build does not have an application archive url');
}
let extraArtifactPaths: Record<string, string> = {};
if (allArtifacts) {
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
}
if (jsonFlag) {
const jsonResults = { path: buildArtifactPath };
const jsonResults = {
...(buildArtifactPath != null && { path: buildArtifactPath }),
...extraArtifactPaths,
};
printJsonOnlyOutput(jsonResults);
} else {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
if (buildArtifactPath != null) {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
}
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
}
}
}
private async getBuildAsync({
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
const artifacts = build.artifacts;
if (!artifacts) {
return {};
}
const extraArtifacts: Array<{ name: string; url: string }> = [];
if (artifacts.buildArtifactsUrl) {
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
}
if (artifacts.xcodeBuildLogsUrl) {
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
}
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
}
if (extraArtifacts.length === 0) {
return {};
}
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
await fs.ensureDir(outputDir);
const downloaded: Record<string, string> = {};
for (const { name, url } of extraArtifacts) {
const fileName = getFileNameFromUrl(url, name);
const outputPath = path.join(outputDir, fileName);
await downloadFileWithProgressTrackerAsync(
url,
outputPath,
(ratio, total) =>
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
`Successfully downloaded ${name}`
);
downloaded[name] = outputPath;
}
return downloaded;
}
private async getBuildByIdAsync({
graphqlClient,
buildId,
}: {
graphqlClient: ExpoGraphqlClient;
buildId: string;
}): Promise<BuildFragment> {
try {
return await BuildQuery.byIdAsync(graphqlClient, buildId);
} catch (error: any) {
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
}
}
private async getBuildByFingerprintAsync({
graphqlClient,
projectId,
platform,
@@ -108,7 +224,7 @@ export default class Download extends EasCommand {
limit: 1,
});
if (builds.length === 0) {
throw new Errors.CLIError(
throw new Error(
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
);
}
@@ -123,7 +239,7 @@ export default class Download extends EasCommand {
build.id,
platform
);
if (await pathExists(cachedBuildArtifactPath)) {
if (await fs.pathExists(cachedBuildArtifactPath)) {
Log.newLine();
Log.log(`Using cached build...`);
return cachedBuildArtifactPath;
@@ -167,3 +283,33 @@ async function resolvePlatformAsync({
});
return selectedPlatform;
}
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
const names: string[] = [];
if (build.artifacts?.buildArtifactsUrl) {
names.push('buildArtifacts');
}
if (build.artifacts?.xcodeBuildLogsUrl) {
names.push('xcodeBuildLogs');
}
if (
build.artifacts?.buildUrl &&
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
) {
names.push('build');
}
return names;
}
function getFileNameFromUrl(url: string, fallbackName: string): string {
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
if (basename) {
return basename;
}
} catch {
// fall through to default
}
return fallbackName;
}
@@ -215,7 +215,6 @@ export default class BuildResign extends EasCommand {
},
},
secrets: prepareCredentialsToResign(credentialsResult.credentials),
builderEnvironment: { image: 'default' },
};
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
buildId: build.id,
@@ -0,0 +1,118 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ChannelInsightsQuery } from '../../../graphql/queries/ChannelInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelInsights from '../insights';
jest.mock('../../../graphql/queries/ChannelInsightsQuery', () => ({
ChannelInsightsQuery: { viewChannelRuntimeInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewChannelRuntimeInsightsAsync = jest.mocked(
ChannelInsightsQuery.viewChannelRuntimeInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const insightsResponse = {
__typename: 'UpdateChannelRuntimeInsights' as const,
id: 'rti',
embeddedUpdateTotalUniqueUsers: 50,
mostPopularUpdates: [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
],
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'd1', label: 'iOS', data: [50] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'cd1', label: 'Launches', data: [42] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint' as const, id: 'launches', label: 'Launches', data: 42 },
],
},
};
describe(ChannelInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'project-1';
beforeEach(() => {
jest.clearAllMocks();
mockViewChannelRuntimeInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): ChannelInsights {
const command = new ChannelInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('passes appId, channel, runtimeVersion and default 7-day timespan to the query', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--channel', 'production', '--runtime-version', '1.0.0']);
await command.runAsync();
expect(mockViewChannelRuntimeInsightsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
channelName: 'production',
runtimeVersion: '1.0.0',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
});
jest.useRealTimers();
});
it('emits JSON when --json is passed', async () => {
const command = createCommand([
'--channel',
'production',
'--runtime-version',
'1.0.0',
'--json',
'--non-interactive',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
channel: 'production',
runtimeVersion: '1.0.0',
embeddedUpdateTotalUniqueUsers: 50,
})
);
});
it('requires --channel and --runtime-version', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
});
});
@@ -0,0 +1,88 @@
import { Flags } from '@oclif/core';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../../channel/insights/formatInsights';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelInsights extends EasCommand {
static override description =
'display adoption, crash, and unique-user insights for a channel + runtime version';
static override flags = {
channel: Flags.string({
description: 'Name of the channel.',
required: true,
}),
'runtime-version': Flags.string({
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const insights = await ChannelInsightsQuery.viewChannelRuntimeInsightsAsync(graphqlClient, {
appId: projectId,
channelName: flags.channel,
runtimeVersion: flags['runtime-version'],
startTime,
endTime,
});
const summary = toChannelInsightsSummary(flags.channel, flags['runtime-version'], insights, {
startTime,
endTime,
daysBack,
});
if (json) {
printJsonOnlyOutput(buildChannelInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildChannelInsightsTable(summary));
}
}
}
+16 -2
View File
@@ -362,8 +362,22 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let uploadError: unknown;
try {
await uploadAssetsAsync(assetFiles, deployResult);
} catch (error) {
uploadError = error;
}
try {
await finalizeDeployAsync(deployResult);
} catch (finalizeError) {
if (!uploadError) {
throw finalizeError;
}
}
if (uploadError) {
throw uploadError;
}
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -0,0 +1,371 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../../../../commandUtils/errors';
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import IntegrationsAscConnect from '../connect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
jest.mock('../../../../integrations/asc/ascApiKey');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
const mockRemoteApps = [
{
ascAppIdentifier: '9876543210',
bundleIdentifier: 'com.test.newapp',
name: 'New App',
appStoreIconUrl: null,
},
];
describe(IntegrationsAscConnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const actor = { id: 'actor-id' } as any;
const analytics = {} as any;
const vcsClient = {} as any;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('fails when already connected', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscConnect(
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
});
it('connects successfully in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'key-id',
});
});
it('accepts Apple key identifier in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'eas-key-uuid',
});
});
it('requires --api-key-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
});
it('requires --asc-app-id in non-interactive mode', async () => {
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
});
it('fails when asc-app-id is not found among discovered apps', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
});
it('fails when passing EAS key id in non-interactive mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No App Store Connect API key found with Apple key identifier'
);
});
it('fails when multiple keys match Apple key identifier', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'eas-key-uuid-1',
keyIdentifier: 'FAKEKEY000',
},
{
id: 'eas-key-uuid-2',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'Multiple App Store Connect API keys match Apple key identifier'
);
});
it('fails when no accessible apps are discovered', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow(
'No accessible apps found on App Store Connect'
);
});
it('fails when app discovery throws', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
.mockRejectedValueOnce(new Error('discovery failed'));
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
{
id: 'key-id',
keyIdentifier: 'FAKEKEY000',
},
] as any);
const command = new IntegrationsAscConnect(
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await expect(command.runAsync()).rejects.toThrow('discovery failed');
});
it('creates or selects key when no api-key-id is provided', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected)
.mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
jest
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
projectDir: '/test/project',
analytics,
vcsClient,
loggedIn: { graphqlClient, actor },
});
await command.runAsync();
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
ascAppIdentifier: '9876543210',
appStoreConnectApiKeyId: 'generated-key-id',
});
});
});
@@ -0,0 +1,160 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import { toggleConfirmAsync } from '../../../../prompts';
import IntegrationsAscDisconnect from '../disconnect';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
jest.mock('../../../../log');
jest.mock('../../../../ora');
jest.mock('../../../../prompts');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscDisconnect, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('disconnects successfully with --yes', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataConnected)
.mockResolvedValueOnce(mockMetadataDisconnected);
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
graphqlClient,
'asc-app-link-id'
);
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
2,
graphqlClient,
testProjectId,
{
useCache: false,
}
);
});
it('no-op when already disconnected', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output when already disconnected in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "disconnect"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('cancels disconnection when confirmation is rejected', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
const command = new IntegrationsAscDisconnect([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('process.exit called');
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
processExitSpy.mockRestore();
});
});
@@ -0,0 +1,155 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import IntegrationsAscStatus from '../status';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora');
const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
remoteAppStoreConnectApp: {
ascAppIdentifier: '1234567890',
bundleIdentifier: 'com.test.app',
name: 'Test App',
appStoreIconUrl: null,
},
},
};
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
describe(IntegrationsAscStatus, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
it('displays connected status', async () => {
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('displays disconnected status', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalled();
});
it('prints json output in json mode', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockResolvedValueOnce(mockMetadataDisconnected);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"action": "status"')
);
});
it('handles invalid ASC API key gracefully', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
expect.stringContaining('revoked or is no longer valid')
);
});
it('handles invalid ASC API key with json output', async () => {
const ascError = new CombinedError({
graphQLErrors: [
new GraphQLError(
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
),
],
});
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
const command = new IntegrationsAscStatus(['--json'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await command.runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
expect.stringContaining('"status": "invalid"')
);
});
it('throws when fetching status fails', async () => {
jest
.mocked(AscAppLinkQuery.getAppMetadataAsync)
.mockRejectedValueOnce(new Error('status failed'));
const command = new IntegrationsAscStatus([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId: testProjectId,
loggedIn: { graphqlClient },
});
await expect(command.runAsync()).rejects.toThrow('status failed');
});
});
@@ -0,0 +1,197 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasCommandError } from '../../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { CredentialsContext } from '../../../credentials/context';
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
export default class IntegrationsAscConnect extends EasCommand {
static override description = 'connect a project to an App Store Connect app';
static override flags = {
'api-key-id': Flags.string({
description: 'Apple App Store Connect API Key ID',
}),
'asc-app-id': Flags.string({
description: 'App Store Connect app identifier',
}),
'bundle-id': Flags.string({
description: 'Filter discovered apps by bundle identifier',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscConnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (nonInteractive) {
if (!flags['api-key-id']) {
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
}
if (!flags['asc-app-id']) {
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
}
}
const {
projectId,
projectDir,
loggedIn: { actor, graphqlClient },
analytics,
vcsClient,
} = await this.getContextAsync(IntegrationsAscConnect, {
nonInteractive,
});
// Step 1: Check current status
const statusSpinner = ora('Checking current App Store Connect app link status').start();
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
statusSpinner.succeed('Checked current status');
if (metadata.appStoreConnectApp) {
throw new EasCommandError(
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
);
}
// Step 2: Get ASC API key
const keysSpinner = ora('Fetching App Store Connect API keys').start();
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
graphqlClient,
metadata.ownerAccount.name
);
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
let apiKeyId = flags['api-key-id'];
if (!apiKeyId) {
const credentialsContext = new CredentialsContext({
projectInfo: null,
nonInteractive,
projectDir,
user: actor,
graphqlClient,
analytics,
vcsClient,
});
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
credentialsContext,
existingKeys: keys,
ownerAccount: metadata.ownerAccount,
});
} else {
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
if (keysByAppleId.length > 1) {
throw new EasCommandError(
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
);
} else if (keysByAppleId.length === 1) {
apiKeyId = keysByAppleId[0].id;
} else {
throw new EasCommandError(
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
);
}
}
if (!apiKeyId) {
throw new EasCommandError('No App Store Connect API key selected.');
}
// Step 3: Discover remote apps
const discoverSpinner = ora('Discovering App Store Connect apps').start();
let remoteApps: DiscoveredAscApps;
try {
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
graphqlClient,
apiKeyId,
flags['bundle-id']
);
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
} catch (err) {
discoverSpinner.fail('Failed to discover apps');
throw err;
}
if (remoteApps.length === 0) {
throw new EasCommandError(
'No accessible apps found on App Store Connect for the selected API key.' +
(flags['bundle-id']
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
: '')
);
}
// Step 4: Select remote app
let selectedApp: DiscoveredAscApps[number];
if (flags['asc-app-id']) {
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
if (!match) {
throw new EasCommandError(
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
);
}
selectedApp = match;
} else {
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
'Select an App Store Connect app:',
remoteApps.map(app => ({
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
value: app,
}))
);
}
// Step 5: Create link
const createSpinner = ora('Connecting project to App Store Connect app').start();
try {
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
appId: metadata.id,
ascAppIdentifier: selectedApp.ascAppIdentifier,
appStoreConnectApiKeyId: apiKeyId,
});
createSpinner.succeed('Connected project to App Store Connect app');
} catch (err) {
createSpinner.fail('Failed to connect project');
throw err;
}
// Step 6: Refetch and display
const refetchSpinner = ora('Verifying connection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
refetchSpinner.succeed('Verified connection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
}
@@ -0,0 +1,143 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscDisconnect extends EasCommand {
static override description = 'disconnect the current project from its App Store Connect app';
static override flags = {
yes: Flags.boolean({
description: 'Skip confirmation prompt',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscDisconnect);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscDisconnect, {
nonInteractive: nonInteractive || flags.yes,
});
// Step 1: Check current status
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
);
}
return;
}
if (!metadata.appStoreConnectApp) {
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
} else {
Log.addNewLineIfNone();
Log.log(
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
);
}
return;
}
// Step 2: Confirm
if (!flags.yes && !nonInteractive) {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
Log.newLine();
Log.warn(
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error('Canceled disconnection');
process.exit(1);
}
}
// Step 3: Delete
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
try {
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
graphqlClient,
metadata.appStoreConnectApp.id
);
deleteSpinner.succeed('Disconnected App Store Connect app');
} catch (err) {
deleteSpinner.fail('Failed to disconnect App Store Connect app');
throw err;
}
// Step 4: Refetch and display
const refetchSpinner = ora('Verifying disconnection').start();
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
useCache: false,
});
refetchSpinner.succeed('Verified disconnection');
if (json) {
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(updatedMetadata));
}
}
private async fetchCurrentStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Checking current App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Checked current status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to check current status');
throw err;
}
}
}
@@ -0,0 +1,84 @@
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import {
buildInvalidJsonOutput,
buildJsonOutput,
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../../../integrations/asc/utils';
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../log';
import { ora } from '../../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class IntegrationsAscStatus extends EasCommand {
static override description =
'show the App Store Connect app link status for the current project';
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(IntegrationsAscStatus);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsAscStatus, {
nonInteractive,
});
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
if (!metadata) {
if (json) {
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
} else {
Log.addNewLineIfNone();
Log.warn(
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
);
}
return;
}
if (json) {
printJsonOnlyOutput(buildJsonOutput('status', metadata));
} else {
Log.addNewLineIfNone();
Log.log(formatAscAppLinkStatus(metadata));
}
}
private async fetchStatusAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
const spinner = ora('Fetching App Store Connect app link status').start();
try {
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
spinner.succeed('Fetched App Store Connect app link status');
return metadata;
} catch (err) {
if (isAscAuthenticationError(err)) {
spinner.fail('App Store Connect connection is invalid');
return null;
}
spinner.fail('Failed to fetch App Store Connect app link status');
throw err;
}
}
}
@@ -0,0 +1,293 @@
import spawnAsync from '@expo/spawn-async';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexTeamConnectionData,
SetupConvexProjectResultData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { Actor } from '../../../../user/User';
import IntegrationsConvexConnect from '../connect';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('@expo/spawn-async');
jest.mock('fs-extra');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testProjectDir = '/test/project';
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor: Actor = {
__typename: 'User',
id: 'test-user-id',
username: testAccountName,
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
users: [],
},
preferences: { onboarding: null },
accounts: [],
};
const mockRobotActor: Actor = {
__typename: 'Robot',
id: 'robot-1',
featureGates: {},
isExpoAdmin: false,
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockSetupResult: SetupConvexProjectResultData = {
convexDeploymentName: 'happy-otter-123',
convexDeploymentUrl: 'https://happy-otter-123.convex.cloud',
deployKey: 'dev:happy-otter-123|abc123token',
convexProject: {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'testapp',
convexProjectSlug: 'testapp',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
},
};
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsConvexConnect {
const command = new IntegrationsConvexConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp' },
projectDir: testProjectDir,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(fs.readFile).mockResolvedValue('' as never);
jest.mocked(spawnAsync).mockResolvedValue({} as never);
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue('aws-us-east-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(promptAsync).mockImplementation(async (params: any) => ({
[params.name]: params.initial,
}));
jest.mocked(ConvexMutation.createConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(ConvexMutation.setupConvexProjectAsync).mockResolvedValue(mockSetupResult);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
});
it('creates a Convex team and project with the new mutation shape', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
});
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('uses an existing Convex team connection', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand(['--region', 'aws-eu-west-1']).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-eu-west-1',
projectName: 'testapp',
});
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('does not create a Convex team if installing the Convex package fails first', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
jest.mocked(spawnAsync).mockRejectedValue(new Error('install failed') as never);
await expect(createCommand([]).runAsync()).rejects.toThrow('install failed');
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).not.toHaveBeenCalled();
});
it('skips the verified email invite when the actor has no email', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([], mockRobotActor).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('logs the error when sending a verified email invite fails', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest
.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync)
.mockRejectedValue(new Error('Convex invite service unavailable'));
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to send Convex team invitation to [email protected]')
);
expect(Log.warn).toHaveBeenCalledWith('Convex invite service unavailable');
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('asks before resending a recent team invite for an existing connection', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
invitedEmail: '[email protected]',
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});
it('writes the deploy key and Convex URL to .env.local', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([]).runAsync();
const envPath = `${testProjectDir}/.env.local`;
const writeCall = jest.mocked(fs.writeFile).mock.calls.find(call => call[0] === envPath);
expect(writeCall?.[1]).toContain('CONVEX_DEPLOY_KEY=dev:happy-otter-123|abc123token');
expect(writeCall?.[1]).toContain('EXPO_PUBLIC_CONVEX_URL=https://happy-otter-123.convex.cloud');
});
it('prompts before overwriting existing Convex .env.local values', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('CONVEX_DEPLOY_KEY=old-key\n' as never);
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('already contains CONVEX_DEPLOY_KEY'),
});
});
it('uses defaults in non-interactive mode', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand(['--non-interactive']).runAsync();
expect(selectAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
})
);
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
appId: testProjectId,
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
})
);
});
});
@@ -0,0 +1,125 @@
import openBrowserAsync from 'better-opn';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import IntegrationsConvexDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsConvexDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexDashboard {
const command = new IntegrationsConvexDashboard([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
});
it('opens the linked Convex project dashboard', async () => {
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
});
it('fails the spinner when the browser cannot be opened', async () => {
jest.mocked(openBrowserAsync).mockResolvedValue(false);
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await createCommand().runAsync();
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
it('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('open failed'));
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(spinner as any);
await expect(createCommand().runAsync()).rejects.toThrow('open failed');
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
});
@@ -0,0 +1,120 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import IntegrationsConvexProjectDelete from '../project/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexProjectDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(argv: string[]): IntegrationsConvexProjectDelete {
const command = new IntegrationsConvexProjectDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(ConvexMutation.deleteConvexProjectAsync).mockResolvedValue('convex-project-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the linked Convex project after confirmation', async () => {
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex project link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import IntegrationsConvexProject from '../project';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
describe(IntegrationsConvexProject, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexProject {
const command = new IntegrationsConvexProject([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { slug: 'testapp' },
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
});
it('prints linked Convex project metadata', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex project linked to testapp')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('project-123'));
});
it('prints an empty state when no Convex project is linked', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
});
});
@@ -0,0 +1,187 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamDelete from '../team/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(argv: string[]): IntegrationsConvexTeamDelete {
const command = new IntegrationsConvexTeamDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.deleteConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the selected Convex team link after confirmation', async () => {
await createCommand(['test-team']).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand(['test-team']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex team link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['test-team', '--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
});
it('logs an empty state when no team links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('throws when the provided team is not linked to the account', async () => {
await expect(createCommand(['missing-team']).runAsync()).rejects.toThrow(
'Convex team missing-team is not linked to this account.'
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive', '--yes']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
it('prompts to select a team link when multiple links exist in interactive mode', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand(['--yes']).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to remove',
expect.any(Array)
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
});
@@ -0,0 +1,220 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamInvite from '../team/invite';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamInvite, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor = {
__typename: 'User',
id: 'test-user-id',
email: '[email protected]',
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(
argv: string[],
actor: { __typename: string; [key: string]: any } = mockActor
): IntegrationsConvexTeamInvite {
const command = new IntegrationsConvexTeamInvite(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('sends an invite for the linked Convex team', async () => {
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
});
it('prints previous invite metadata when it exists', async () => {
await createCommand([]).runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('connection-1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Previous invite'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('[email protected]'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('2024-01-02T00:00:00.000Z'));
});
it('prompts before resending a recent invite', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips resending a recent invite when the user does not confirm', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});
it('resends recent invites in non-interactive mode with a warning', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand(['--non-interactive']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips the invite when the actor has no email', async () => {
await createCommand([], { __typename: 'Robot', id: 'robot-1' }).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('selects a team link when multiple are available', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to invite yourself to',
expect.any(Array)
);
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive']).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
});
@@ -0,0 +1,78 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import IntegrationsConvexTeam from '../team';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../log');
describe(IntegrationsConvexTeam, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
function createCommand(): IntegrationsConvexTeam {
const command = new IntegrationsConvexTeam([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
});
it('prints linked Convex team metadata', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex teams linked to @testuser')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('prints an empty state when no Convex teams are linked', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
});
});
@@ -0,0 +1,350 @@
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../../commandUtils/EasCommand';
import { confirmRecentConvexInviteAsync, formatConvexTeam } from '../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { ConvexMutation } from '../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../graphql/types/ConvexTeamConnection';
import Log from '../../../log';
import { ora } from '../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { Actor } from '../../../user/User';
const CONVEX_REGIONS = [
{ title: 'US East (aws-us-east-1)', value: 'aws-us-east-1' },
{ title: 'EU West (aws-eu-west-1)', value: 'aws-eu-west-1' },
];
const DEFAULT_REGION = 'aws-us-east-1';
export default class IntegrationsConvexConnect extends EasCommand {
static override description = 'connect Convex to your Expo project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EASNonInteractiveFlag,
region: Flags.string({
description: 'Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)',
options: CONVEX_REGIONS.map(r => r.value),
}),
'team-name': Flags.string({
description: 'Name for the new Convex team (defaults to EAS account name)',
}),
'project-name': Flags.string({
description: 'Name for the Convex project (defaults to app slug)',
}),
};
async runAsync(): Promise<void> {
const {
flags: {
region: regionFlag,
'team-name': teamNameFlag,
'project-name': projectNameFlag,
'non-interactive': nonInteractive,
},
} = await this.parse(IntegrationsConvexConnect);
const {
privateProjectConfig: { projectId, exp, projectDir },
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(IntegrationsConvexConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
// 1. Check for existing Convex team connections
const existingConnections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
let connection: ConvexTeamConnectionData | null = null;
let teamName: string | null = null;
if (existingConnections.length === 0) {
// 2a. No connection - resolve a team name and create it after local package install succeeds
teamName = await this.resolveTeamNameAsync(teamNameFlag, account.name, nonInteractive);
} else if (existingConnections.length === 1) {
// 2b. Single existing connection
connection = existingConnections[0];
Log.withTick(`Using existing Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} else {
// 2c. Multiple connections - prompt to select
connection = await this.selectConnectionAsync(existingConnections, nonInteractive);
}
// 3. Resolve project name before project setup mutation
const projectName = await this.resolveProjectNameAsync(
projectNameFlag,
exp.slug,
nonInteractive
);
// 4. Install the Convex package before creating new server-side resources
await this.installConvexPackageAsync(projectDir);
if (!connection) {
const spinner = ora('Creating Convex team').start();
try {
connection = await ConvexMutation.createConvexTeamConnectionAsync(graphqlClient, {
accountId: account.id,
deploymentRegion: region,
convexTeamName: teamName ?? account.name,
});
spinner.succeed(`Created Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} catch (error) {
spinner.fail('Failed to create Convex team');
throw error;
}
}
// 5. Set up Convex project
const spinner = ora('Setting up Convex project').start();
let setupResult;
try {
setupResult = await ConvexMutation.setupConvexProjectAsync(graphqlClient, {
appId: projectId,
convexTeamConnectionId: connection.id,
deploymentRegion: region,
projectName,
});
spinner.succeed(
`Created Convex project ${chalk.bold(projectName)} with deployment ${chalk.bold(setupResult.convexDeploymentName)}`
);
} catch (error) {
spinner.fail('Failed to set up Convex project');
throw error;
}
// 6. Send team invite (non-fatal)
await this.sendTeamInviteAsync(graphqlClient, connection, actor, { nonInteractive });
// 7. Write deploy key and URL to .env.local
await this.writeEnvLocalAsync(
projectDir,
setupResult.deployKey,
setupResult.convexDeploymentUrl,
nonInteractive
);
// 8. Success message
Log.addNewLineIfNone();
Log.log(chalk.green('Convex is ready!'));
Log.newLine();
Log.log('Next steps:');
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
Log.newLine();
if (this.getActorEmail(actor)) {
Log.log(
`Check your email for an invitation to join your Convex team. Accept it for full dashboard access.`
);
}
}
private async resolveRegionAsync(
flagValue: string | undefined,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return DEFAULT_REGION;
}
return await selectAsync('Select a Convex deployment region', CONVEX_REGIONS);
}
private async resolveTeamNameAsync(
flagValue: string | undefined,
accountName: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return accountName;
}
const { teamName } = await promptAsync({
type: 'text',
name: 'teamName',
message: 'Convex team name',
initial: accountName,
validate: (value: string) => (value.trim() ? true : 'Team name cannot be empty'),
});
return teamName;
}
private async resolveProjectNameAsync(
flagValue: string | undefined,
slug: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return slug;
}
const { projectName } = await promptAsync({
type: 'text',
name: 'projectName',
message: 'Convex project name',
initial: slug,
validate: (value: string) => (value.trim() ? true : 'Project name cannot be empty'),
});
return projectName;
}
private async selectConnectionAsync(
connections: ConvexTeamConnectionData[],
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (nonInteractive) {
return connections[0];
}
const choices = connections.map(c => ({
title: `${formatConvexTeam(c)} (created ${new Date(c.createdAt).toLocaleDateString()})`,
value: c,
}));
return await selectAsync('Select a Convex team connection', choices);
}
private getActorEmail(actor: Actor): string | null {
return actor.__typename === 'User' ? actor.email : null;
}
private async sendTeamInviteAsync(
graphqlClient: Parameters<typeof ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync>[0],
connection: ConvexTeamConnectionData,
actor: Actor,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return;
}
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
}
try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
Log.withTick(`Sent Convex team invitation to ${chalk.bold(email)}`);
} catch (error) {
Log.warn(
`Failed to send Convex team invitation to ${email}. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} to retry.`
);
Log.warn(error instanceof Error ? error.message : String(error));
}
}
private async installConvexPackageAsync(projectDir: string): Promise<void> {
Log.newLine();
Log.log(`Running ${chalk.bold('npx expo install convex')}`);
Log.newLine();
try {
await spawnAsync('npx', ['expo', 'install', 'convex'], {
cwd: projectDir,
stdio: 'inherit',
});
Log.withTick(`Installed the ${chalk.bold('convex')} npm package`);
} catch (error) {
Log.warn(
`Failed to install the ${chalk.bold('convex')} npm package. Run ${chalk.cyan(
'npx expo install convex'
)} from your project directory, then run ${chalk.cyan('npx convex dev')}.`
);
throw error;
}
}
private async writeEnvLocalAsync(
projectDir: string,
deployKey: string,
convexUrl: string,
nonInteractive: boolean
): Promise<void> {
const envPath = path.join(projectDir, '.env.local');
let existingContent: Record<string, string> = {};
let rawContent = '';
if (await fs.pathExists(envPath)) {
rawContent = await fs.readFile(envPath, 'utf8');
existingContent = dotenv.parse(rawContent);
if (existingContent.CONVEX_DEPLOY_KEY && !nonInteractive) {
const overwrite = await confirmAsync({
message: `.env.local already contains CONVEX_DEPLOY_KEY. Overwrite Convex values?`,
});
if (!overwrite) {
Log.log('Skipping .env.local update. Deploy key:');
Log.log(` CONVEX_DEPLOY_KEY=${deployKey}`);
Log.log(` EXPO_PUBLIC_CONVEX_URL=${convexUrl}`);
return;
}
}
}
const updatedContent = this.mergeEnvContent(rawContent, {
CONVEX_DEPLOY_KEY: deployKey,
EXPO_PUBLIC_CONVEX_URL: convexUrl,
});
await fs.writeFile(envPath, updatedContent);
Log.withTick(`Wrote Convex config to ${chalk.bold('.env.local')}`);
}
private mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
let content = rawContent;
const keysToAdd: Record<string, string> = { ...newVars };
for (const [key, value] of Object.entries(newVars)) {
// Replace existing line if present
const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
delete keysToAdd[key];
}
}
// Append any keys that weren't already in the file
const remaining = Object.entries(keysToAdd);
if (remaining.length > 0) {
if (content.length > 0 && !content.endsWith('\n')) {
content += '\n';
}
for (const [key, value] of remaining) {
content += `${key}=${value}\n`;
}
}
return content;
}
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more