Compare commits

...
25 Commits
Author SHA1 Message Date
Expo CI 88e23aaa09 v27.1.0 2026-10-28 16:52:14 +00:00
Will Schurman 🦦 0a274a6fef [eas-cli] Throw when environment variable pull fails (#4774)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

With protected environment variables (and even regular usage without protected environment variables), we need to prevent the build running when they can't be pulled. Otherwise the build can end up in a unexpected state where env variables are empty or unset.

# How

Re-throw the error when environment variable pulling fails. The original PR that added this (#2495) didn't specify why it was caught and allowed to proceed.

# Test Plan

Before:

```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
[GraphQL] Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues
✔ Incremented versionCode from 11 to 12.


✔ Using remote Android credentials (Expo server)
```

After:
```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user or if the permissions granted during authentication are insufficient to perform the operation.
Run eas whoami to check the user you are logged in as and your permission grant.
Run eas login to change the user or permission grant.

Original error message: Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
Request ID: 11ade772-f079-4515-84bc-3c5239bf35b7
```

Note that this ^^ is just the env evaluation for config, but the same function is used in `eas build:dev`, `build:resign`, `build:version:get/set/sync`, `eas config`, and `eas fingerprint:generate`.
2026-10-28 09:03:43 -07:00
Stanisław Chmiela 78f6782970 [build-tools] Handle ASC 90189 as version duplicate (#3461)
# Why

ASC error 90189 arrives after upload creation. The existing duplicate-version handler covers the creation request, so it does not handle this processing failure.

# How

Map error 90189 to the existing duplicate-build UserError. Include Apple’s original descriptions and codes in the public error message and retain the full failed-upload state as `cause`, including errors, warnings, and information messages. Keep the current SDK-version handler. Remove unrelated message edits copied into the old PR.

# Examples
- https://expo.dev/uuid/019cbf7e-e0f8-7212-9b72-37e97d43b7a2
- https://expo.dev/uuid/019cbf7d-28fa-747b-bd36-81f81b8d8d1b
- https://expo.dev/uuid/019cbf7a-b73a-7310-b845-4e2f8c58224c

# Datadog evidence

Checked on 23 October 2026. Count window: 16 October 2026 12:00 UTC to 23 October 2026 12:00 UTC (14:00 Warsaw time). Counts refer to distinct runs or builds with failed phases, not raw log lines.

187 distinct failed runs contain error 90189. In 173 runs, the validation error list has no other code, so it matches this PR’s condition. Mixed validation errors remain outside that condition.

- [Datadog: matching failed-phase logs](https://app.datadoghq.com/logs?query=service%3Aeas-build+build_phase_result%3Afailed+submission_platform%3Aios+%2290189%22&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)
- [Datadog: example failed run or build](https://app.datadoghq.com/logs?query=service%3Aeas-build+job_run_id%3A01a16866-c973-7288-b710-eb556df52e1c&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)

# Test Plan

- `yarn --cwd packages/build-tools jest-unit --runInBand src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- Root type checks, lint, and formatting checks passed.
- All 19 focused tests passed. The new test for public error details and cause preservation fails on the previous code and passes with this change.
2026-10-28 14:21:02 +01:00
Szymon Świerk 5757091b33 [eas-cli] support nested workflow calls in local file workflow run (#4769)
* [eas-cli] Collect workflow files referenced by uses jobs

* [eas-cli] Upload referenced workflow files in workflow:run

* [eas-cli] Regenerate GraphQL types
2026-10-28 11:40:21 +01:00
Ash Wu dd6ad17179 [eas-cli] recognize queued workflow runs and report finishedAt only for final runs (#4258)
Signed-off-by: Ash Wu <[email protected]>
2026-10-28 18:01:28 +08:00
Ash Wu 695f567760 [build-tools][worker] keep NPM_TOKEN working on newer pnpm and explain pnpm Node.js mismatches (#4729)
pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc (GHSA-3qhv-2rgh-x77r) but still expand them in the npm user config. On EAS Build the NPM_TOKEN credential now goes into the file `npm config get userconfig` reports for the build env (~/.npmrc unless NPM_CONFIG_USERCONFIG points elsewhere) whenever the job has NPM_TOKEN, and the project .npmrc is left to the user. Local builds keep creating the project .npmrc, because the home directory there belongs to the developer. The legacy PREPARE_PROJECT path and the eas/use_npm_token step share one helper.

Projects that pin pnpm 11 through packageManager on a Node 20 image crash on node:sqlite right after pnpm's "requires at least Node.js v22.13" banner. A new user error names the eas.json node value to set or the pnpm version to change. The worker raises the same error when it reinstalls pnpm under a node version pinned in eas.json, instead of the generic "Failed to install pnpm" message: the typed error it threw before never reached the log handlers, and the probe's piped output never reached the log.
2026-10-28 17:19:16 +08:00
Ash Wu 15e2302575 [build-tools] install the repack package with npm in an isolated sandbox (#4728) 2026-10-28 17:10:27 +08:00
Gabe Debes ee4f03d44e [ci] Format the generated changelog before release (#4773) 2026-10-27 19:18:51 -07:00
Expo CI 5e192ecba6 v27.0.0 2026-10-27 22:10:54 +00:00
Gabe DebesandClaude Opus 5.5 864b11effe [eas-cli] Add simulator:logs for iOS sessions (#4545)
* [eas-cli] Add simulator:logs

Read buffered or live logs from a running iOS simulator session through
its preview API, or save the logs artifact once the session has stopped.

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

* [eas-cli] Use oclif exclusive for simulator:logs --follow and --json

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

* [eas-cli] Suggest redirecting --follow to save live simulator logs

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

* [eas-cli] Report log source failures while following simulator logs

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

* [eas-cli] Close the connection of a rejected simulator preview response

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

* [eas-cli] Write simulator log health notices to stderr

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

* [eas-cli] Explain that live simulator logs need an iOS session

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 15:02:52 -07:00
Gabe DebesandCodex 62df1bae1c [eas-cli] Require explicit simulator-host upload paths (#4683)
* [eas-cli] Require explicit simulator-host upload paths

* [eas-cli] Cover simulator upload path help and filesystem failures

* [eas-cli] Reject host paths for upload ID actions

Keep upload guidance consistent in text and JSON help, and explain local filenames beginning with host:. Add rejection and help regressions.

Co-authored-by: Codex <[email protected]>
2026-10-27 12:34:27 -07:00
Vojtech NovakandClaude Opus 5.5 8070663faa [eas-cli] Write the reason a command failed to stderr (#4592)
* [eas-cli] Write the reason a command failed to stderr

EasCommand.catch printed the reason with Log.error, which writes to
stdout. oclif then printed "<command> command failed." to stderr. A
program that reads stderr for the error found only that generic line.

Only the catch changes. Other Log.error and Log.warn calls stay on
stdout, because some of them are not failures and the spinner keeps
non-TTY output off stderr on purpose.

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

* [eas-cli] Drop the errorToStderr unit test and doc comment

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

* [eas-cli] Color the failure reason by stderr's color support

chalk's default instance takes its color level from stdout, so
`eas ... 2>err.log` in a terminal wrote color codes to the file.

Also spy on errorToStderr in the already-printed catch test. The catch
no longer calls Log.error, so the test passed even without the
messageAlreadyPrinted guard.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Vojtech NovakandClaude Opus 5.5 e38a5a88d2 [eas-cli] Print the eas-cli dependency notice only when stderr is a terminal (#4591)
The notice is advice for a person. A program that runs eas-cli reads
stderr for the command's error and found this notice first.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Gabe DebesandClaude Opus 5.5 82845def18 [eas-cli] Fix simulator control action help (#4681)
* [eas-cli] Fix simulator control action help

* [eas-cli] Exercise real simulator help lookup and hooks

* [eas-cli] Batch source-loaded help lifecycle probes

* [build-tools] Expect headless Appium default on iOS in the launch test

#4622 added this test while #4699 added appium:isHeadless to the iOS
default capabilities. Each PR passed CI without the other, so main fails.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 17:00:03 -07:00
Gabe DebesandClaude Opus 5.5 476b4e9b09 [build-tools] Run Appium iOS sessions with a headless Simulator (#4699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 16:42:34 -07:00
Gabe Debes 9315aebef5 [build-tools] run apsd cleanup in parallel for iOS Simulator sessions (#4706)
* [build-tools] Keep APSD setup off preview readiness

* [build-tools] Run APSD setup alongside app launch

* [build-tools] Lowercase apsd in the changeset
2026-10-26 16:30:46 -07:00
Gabe Debes 786d8bac85 [build-tools] Start Appium with the prepared Full Disk Access helper (#4622) 2026-10-26 15:41:02 -07:00
Gabe Debes 8f67105e5a [build-tools] Improve simulator sessions failed app downloads error (#4703)
* [build-tools] Explain application archive download failures

* [build-tools] Update download error expectations

* [build-tools] Use neutral archive download advice
2026-10-26 15:06:37 -07:00
Will Schurman 🦦 3bfb405c87 [eas-cli] Fix graphql CI step on stacked PRs (#4763)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Noticed that the required "Ensure GraphQL schema and generated code" CI job wasn't running on stacked PRs, and thus prevented stacked land.

# How

Allow it to run on stacked PRs by removing the `main` condition from PR.

# Test Plan

This PR was stacked and it ran!
2026-10-26 14:59:35 -07:00
Will Schurman 🦦 73335e8233 [eas-cli] Handle unauthorized error during build command (#4760)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

As requested by @sjkim-expo in #4734, when the user has insufficient permissions to create a build due to the required role being higher than the user's granted role, we can show a better error message.

# How

Handle the `UNAUTHORIZED` error code and show a better error.

Note that this doesn't yet gracefully handle protected environment variable errors or protected credential errors.

# Test Plan

`neas build` for granted role lower than required role from eas.json

Output:
```
Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role on the account "real-org-will", but the credentials of the actor that started the job are scoped to the "Developer" role. Re-authenticate to grant the credentials a scope that includes the "Release Manager" role, or request a lower role.
Request ID: bd12f9af-8950-498b-a69b-5d89255ca4f4

Run eas whoami to check the user you are logged in as and your permission grant. Run eas login to change the user or permission grant. To request a lower role for the build's EXPO_TOKEN, set permissions.account.role in the build profile in eas.json.
```
2026-10-26 14:31:37 -07:00
Will Schurman 🦦 9af03f901e [eas-cli] Add better description to re-auth for scoped auth (#4759)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that there's scoping, when a permissions error occurs and the user is scoped, they are told to run `eas login`.

But, when running `eas login` the experience was suboptimal:
```
You are already logged in as wschurman.
  ✔ Do you want to continue? … yes
```

# How

Update this message to be more descriptive and instructing to continue if the user wants to change scope.

# Test Plan

`neas login`

```
You are already logged in as wschurman.

  Accounts:
  • wschurman (Role: Owner)
  • expo (Role: Admin, limited to Viewer by the current credentials)

  The permissions granted during authentication limit your access to some accounts. Continue to log in again and change the
  permission grant.
  ✔ Do you want to continue? … yes
```
2026-10-26 14:07:42 -07:00
Gabe Debes 13ab40e271 [build-tools] Support idle timeouts for iOS web preview sessions (#4731)
* [build-tools] Support idle timeouts for iOS web preview sessions

* [build-tools] Reject Android web preview idle timeouts
2026-10-26 12:23:37 -07:00
Will Schurman 🦦 355f3f2012 [eas-cli] Send requested role from eas.json to build mutation (#4734)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Builds on top of #4732 to send the new field to graphql for build creation. The eas.json field has been deployed on the server.

# How

Read the requested role from config, send to mutation if specified.

# Test Plan

Run the new test.

Manual test:
1. Add `"permissions": { "account": { "role": "runner" } }` to eas.json
2. `neas build`, see build runs!
2026-10-26 11:51:46 -07:00
Gabe Debes 736e1aeb90 [build-tools] Handle device sessions stopped during startup (#4716) 2026-10-26 11:31:14 -07:00
Expo CI 58d372a096 bump STAGING and PRODUCTION to 26.0.0 in cli-versions.json 2026-10-26 16:52:09 +00:00
126 changed files with 7475 additions and 645 deletions

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
+49
View File
@@ -2,6 +2,55 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "26.0.0",
"version": "27.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+19
View File
@@ -1,5 +1,24 @@
# @expo/build-tools
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "26.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "26.0.0",
"@expo/eas-build-job": "26.0.0",
"@expo/downloader": "27.1.0",
"@expo/eas-build-job": "27.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "26.0.0",
"@expo/logger": "27.1.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "26.0.0",
"@expo/template-file": "26.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/steps": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -0,0 +1,33 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,6 +1,7 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -211,6 +212,7 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -4,6 +4,7 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+6 -1
View File
@@ -71,7 +71,12 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync(ctx, ctx.logger);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+1
View File
@@ -34,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -98,7 +98,7 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
@@ -162,6 +162,59 @@ afterEach(() => {
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
@@ -176,6 +176,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
@@ -1,8 +1,10 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -17,17 +19,100 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/spawn-async');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
@@ -103,6 +103,18 @@ async function runAsync(
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -147,7 +159,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -1,6 +1,7 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -202,6 +203,78 @@ describe(installAppiumAsync, () => {
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, true],
[BuildRuntimePlatform.LINUX, false],
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
const helper = '/usr/local/libexec/turtle-tcc-fda';
if (helperPresent) {
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, 'prepared helper');
}
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
// Stop at the launch boundary; no readiness requests or remote session are needed.
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const env = {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
PATH: '/configured/node/bin:/usr/bin',
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
};
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { ...env, NGROK_AUTHTOKEN: 'token' },
} as never
)
).rejects.toBe(launched);
expect(spawnDetached).toHaveBeenCalledTimes(1);
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
expect(launch).toEqual({
command: useHelper ? helper : appiumBinPath,
args: [
...(useHelper ? [appiumBinPath] : []),
'--address',
'127.0.0.1',
'--port',
'4723',
'--base-path',
'/',
'--log-level',
'error',
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
runtimePlatform === BuildRuntimePlatform.DARWIN
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
: '{"appium:eventTimings":true}',
],
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
logger,
});
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -361,6 +434,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
@@ -447,6 +521,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
@@ -84,11 +84,52 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -268,6 +309,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -1,7 +1,15 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -45,6 +53,20 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -110,3 +132,62 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -0,0 +1,39 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/project';
const homeDirectory = '/home/expo';
describe(createSetUpNpmrcBuildFunction, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
NpmrcAuthTokenLine
);
});
});
@@ -312,8 +312,22 @@ export async function downloadBuildAsync(
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction {
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: resolvedRepackVersion,
versionSpec: inputs.repack_version.value as string,
pathEnv: env.PATH,
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
logger: stepsCtx.logger,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -226,16 +220,28 @@ function createOutputPath({
*/
async function installAndImportRepackAsync({
packageName,
version,
versionSpec,
pathEnv,
npmCacheUrl,
logger,
}: {
packageName: string;
version: string;
versionSpec: string;
pathEnv: string | undefined;
npmCacheUrl: string | undefined;
logger: bunyan;
}): Promise<typeof import('@expo/repack-app')> {
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
stdio: 'inherit',
cwd: sandbox,
await installIsolatedNpmPackageAsync({
packageSpec: `${packageName}@${versionSpec}`,
directory: sandbox,
npmCacheUrl,
pathEnv,
logger,
});
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
return require(resolveFrom(sandbox, packageName));
}
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -239,6 +239,9 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -65,6 +65,7 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
@@ -225,9 +226,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: appiumBinPath,
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
@@ -242,7 +246,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
@@ -278,7 +288,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
authtoken: ngrokAuthtoken,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -296,6 +306,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -374,6 +374,9 @@ export function createStartArgentRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -57,6 +58,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -68,9 +74,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
@@ -129,7 +146,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -147,6 +164,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -154,6 +174,14 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
@@ -262,6 +262,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isRedundantBinaryUploadError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -344,6 +357,10 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -1,9 +1,6 @@
import { BuildFunction } from '@expo/steps';
import fs from 'fs-extra';
import path from 'path';
import { NpmrcTemplate } from '../../templates/npmrc';
import { findPackagerRootDir } from '../../utils/packageManager';
import { setUpNpmrcAsync } from '../../utils/npmrc';
export function createSetUpNpmrcBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
name: 'Use NPM_TOKEN',
__metricsId: 'eas/use_npm_token',
fn: async (stepCtx, { env }) => {
const { logger } = stepCtx;
if (env.NPM_TOKEN) {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
logger.info('Creating .npmrc in your project directory with the following contents:');
logger.info(NpmrcTemplate);
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
}
} else {
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(
`.npmrc found at ${path.relative(
stepCtx.global.projectTargetDirectory,
projectNpmrcPath
)}`
);
}
}
await setUpNpmrcAsync({
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
workingDirectory: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
});
},
});
}
@@ -186,6 +186,7 @@ describe(runAgentAsync, () => {
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@anthropic-ai/[email protected]',
@@ -170,6 +170,53 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('handles the structured stopped code without matching the message', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
],
},
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).resolves.toBe('stopped');
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
});
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).rejects.toBeInstanceOf(SystemError);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
@@ -364,7 +411,9 @@ describe('with a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500): stop here');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -589,7 +638,9 @@ describe('without a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500)');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
});
it.each([true, false])(
'waits for setup before launch or boot-only readiness (app: %s)',
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
async hasApp => {
const setup = deferred<void>();
const prepared = deferred<ServeSimApplicationOptions | void>();
@@ -1468,70 +1468,78 @@ it.each([true, false])(
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
: undefined
);
if (!hasApp) {
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
if (hasApp) {
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(reported).toBe(false);
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
} else {
expect(runServeSimActionAsync).not.toHaveBeenCalled();
setup.resolve();
const host = await starting;
expect(runServeSimActionAsync).not.toHaveBeenCalled();
await host.finishAsync();
return;
}
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
setup.resolve();
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(
jest
.mocked(runServeSimActionAsync)
.mock.calls.map(([options]) => [options.action, options.params])
).toEqual([
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]);
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
).toEqual(
hasApp
? [
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]
: []
);
if (hasApp) {
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
}
let queried = false;
ready.resolve({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
const host = await starting;
await host.finishAsync();
await expect(access(directory)).rejects.toThrow();
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
try {
expect(reported).toBe(true);
const host = await starting;
let finished = false;
const finishing = host.finishAsync().then(() => {
finished = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
setup.resolve();
await finishing;
} finally {
setup.resolve();
await (await starting).finishAsync();
}
if (hasApp) {
await expect(access(directory)).rejects.toThrow();
}
}
);
@@ -1676,7 +1684,8 @@ it('warns and continues launching when disabling push fails', async () => {
await host.finishAsync();
});
it('cancels pending setup without launching and observes its late failure', async () => {
it('cancels pending app preparation and observes late setup failure', async () => {
const application = deferred<ServeSimApplicationOptions>();
const controller = new AbortController();
let rejectSetup!: (error: Error) => void;
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
@@ -1690,7 +1699,7 @@ it('cancels pending setup without launching and observes its late failure', asyn
logger,
timeoutMs: 2_000,
iosSimulatorUdid: 'emulator-5554',
launchAppIdentifier: 'dev.example.app',
application: application.promise,
signal: controller.signal,
});
const rejected = expect(starting).rejects.toThrow('session canceled');
@@ -1,6 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
@@ -13,6 +12,7 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
import { killProcessGroup } from '../../utils/processes';
export type AgentKind = 'claude-code' | 'codex';
@@ -76,17 +76,13 @@ export async function runAgentAsync({
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
logger.info(`Installing ${packageSpec}.`);
try {
await turtleSpawn(
'npm',
[
'install',
...['--prefix', cliDirectory],
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
packageSpec,
],
{ env: minimalEnv, signal }
);
await installIsolatedNpmPackageAsync({
packageSpec,
directory: cliDirectory,
npmCacheUrl,
pathEnv,
signal,
});
} catch (error) {
signal.throwIfAborted();
throw new SystemError(
@@ -91,7 +91,8 @@ export class DeviceRunSessionWorkerApiError extends Error {
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions
options?: ErrorOptions,
readonly code?: string
) {
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
@@ -267,7 +268,9 @@ export class DeviceRunSessionWorkerApi {
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status
response.status,
undefined,
apiError?.success ? apiError.data.errors[0]?.code : undefined
);
}
if (!bodyResult.ok) {
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
}
let stagedAppDirectory: string | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await beforeLaunch;
await simulatorSetup;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
@@ -711,7 +711,8 @@ async function startDeviceSessionHostInternalAsync(
);
}
signal?.throwIfAborted();
beforeLaunch = asyncResult(
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
@@ -749,7 +750,6 @@ async function startDeviceSessionHostInternalAsync(
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -31,14 +31,17 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
await uploadRemoteSessionConfigAsync({
const sessionStart = await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
return sessionStart;
}
if (localEgress && !signal?.aborted) {
// Guard refusals from here on show up under this step in the job log,
// alongside the monitor's reports, instead of under the boot step.
@@ -57,4 +60,5 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
});
}
return sessionStart;
}
@@ -31,6 +31,7 @@ import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
DeviceRunSessionWorkerApiError,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
@@ -554,7 +555,7 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
@@ -564,7 +565,16 @@ export async function uploadRemoteSessionConfigAsync({
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
return 'started';
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
return 'stopped';
}
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
+3 -1
View File
@@ -1,3 +1,5 @@
export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}';
export const NpmrcTemplate = `${NpmrcAuthTokenLine}
registry=https://registry.npmjs.org/
`;
@@ -0,0 +1,89 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const spawnMock = jest.mocked(spawn);
const npmCacheUrl = 'https://npm-cache.expo.test';
describe(installIsolatedNpmPackageAsync, () => {
it('retries without the npm cache registry when the install fails against it', async () => {
const logger = createMockLogger();
const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`,
});
spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never);
await installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
logger,
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl]));
expect(spawnMock.mock.calls[1][1]).not.toContain('--registry');
expect(spawnMock.mock.calls[1][2]).toEqual({
env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' },
logger,
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }),
{ level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } }
);
});
it('rethrows an install failure that does not mention the npm cache registry', async () => {
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: 'npm error notarget No matching version found for @scope/fork@next.',
});
spawnMock.mockRejectedValueOnce(error);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
})
).rejects.toBe(error);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('rethrows the cancellation of an install without retrying', async () => {
const controller = new AbortController();
const cancellation = new Error('cancelled');
spawnMock.mockImplementationOnce((() => {
controller.abort(cancellation);
return Promise.reject(
Object.assign(new Error('npm install was killed with SIGTERM'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`,
})
);
}) as unknown as typeof spawn);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
signal: controller.signal,
})
).rejects.toBe(cancellation);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,120 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc';
import { setUpNpmrcAsync } from '../npmrc';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/build';
const homeDirectory = '/home/expo';
describe(setUpNpmrcAsync, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => {
const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents);
await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false');
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe(
`fund=false\n${NpmrcAuthTokenLine}\n`
);
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
projectContents
);
});
it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => {
await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true });
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never);
const env = {
NPM_TOKEN: 'npm_secret_token',
EAS_BUILD_RUNNER: 'eas-build',
NPM_CONFIG_USERCONFIG: '~/npm/npmrc',
};
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env,
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['config', 'get', 'userconfig'],
expect.objectContaining({ cwd: projectTargetDirectory, env })
);
expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe(
`\n${NpmrcAuthTokenLine}\n`
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => {
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
NpmrcTemplate
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('leaves an existing project .npmrc untouched on local builds', async () => {
const existingContents = 'registry=https://registry.example.com/\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
existingContents
);
});
it('reports an existing .npmrc when NPM_TOKEN is not set', async () => {
const logger = createMockLogger();
await vol.promises.writeFile(
`${projectTargetDirectory}/.npmrc`,
'registry=https://registry.example.com/\n'
);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { EAS_BUILD_RUNNER: 'eas-build' },
logger,
});
expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc');
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
});
@@ -1,9 +1,7 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
PackageManager,
@@ -16,7 +14,6 @@ import {
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -293,33 +290,6 @@ describe(resolvePackageInstall, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -0,0 +1,62 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry';
import { Sentry } from '../sentry';
export async function installIsolatedNpmPackageAsync({
packageSpec,
directory,
npmCacheUrl,
pathEnv,
logger,
signal,
}: {
packageSpec: string;
directory: string;
npmCacheUrl: string | undefined;
pathEnv: string | undefined;
logger?: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const installAsync = async (registryUrl: string | undefined): Promise<void> => {
await spawn(
'npm',
[
'install',
'--prefix',
directory,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
...(registryUrl ? ['--registry', registryUrl] : []),
packageSpec,
],
// npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME.
{ env: { PATH: pathEnv, HOME: directory }, logger, signal }
);
};
if (!npmCacheUrl) {
await installAsync(undefined);
return;
}
try {
await installAsync(npmCacheUrl);
} catch (error) {
signal?.throwIfAborted();
if (!isNpmCacheInstallFailure(error, npmCacheUrl)) {
throw error;
}
logger?.warn(
`Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(error), {
level: 'warning',
extras: { packageSpec, npmCacheUrl },
});
await installAsync(undefined);
}
}
@@ -0,0 +1,20 @@
import { SystemError } from '@expo/eas-build-job';
export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
export class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+50 -16
View File
@@ -1,23 +1,56 @@
import { Job } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
import { BuildContext } from '../context';
import { NpmrcTemplate } from '../templates/npmrc';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc';
export async function setUpNpmrcAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
if (ctx.env.NPM_TOKEN) {
await createNpmrcIfNotExistsAsync(ctx, logger);
export async function setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory,
env,
logger,
}: {
projectTargetDirectory: string;
workingDirectory: string;
env: Record<string, string | undefined>;
logger: bunyan;
}): Promise<void> {
if (!env.NPM_TOKEN) {
await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger);
return;
}
logger.info('We detected that you set the NPM_TOKEN environment variable');
// pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc
if (env.EAS_BUILD_RUNNER === 'eas-build') {
await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger);
} else {
await logIfNpmrcExistsAsync(ctx, logger);
await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger);
}
}
async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc');
async function addNpmTokenToUserNpmrcAsync(
workingDirectory: string,
env: Record<string, string | undefined>,
logger: bunyan
): Promise<void> {
const userNpmrcPath = (
await spawn('npm', ['config', 'get', 'userconfig'], {
cwd: findPackagerRootDir(workingDirectory),
env,
stdio: 'pipe',
})
).stdout.trim();
logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`);
await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`);
}
async function createProjectNpmrcIfNotExistsAsync(
projectTargetDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
@@ -27,12 +60,13 @@ async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunya
}
}
async function logIfNpmrcExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
const projectNpmrcPath = path.join(
findPackagerRootDir(ctx.getReactNativeProjectDirectory()),
'.npmrc'
);
async function logIfNpmrcExistsAsync(
projectTargetDirectory: string,
workingDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`);
logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`);
}
}
@@ -1,5 +1,4 @@
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';
@@ -126,34 +125,6 @@ export function resolvePackageInstall(
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
@@ -1,5 +1,13 @@
# create-eas-build-function
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "26.0.0",
"version": "27.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "26.0.0"
"@expo/steps": "27.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+8
View File
@@ -1,5 +1,13 @@
# @expo/downloader
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "26.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,13 @@
# eas-build-cache-provider
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "26.0.0",
"version": "27.1.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+8
View File
@@ -1,5 +1,13 @@
# @expo/eas-build-job
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
### Major Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "26.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "26.0.0",
"@expo/logger": "27.1.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "26.0.0",
"@expo/turtle-spawn": "27.1.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+35
View File
@@ -1,5 +1,40 @@
# eas-cli
## 27.1.0
### Minor Changes
- Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### Patch Changes
- Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Major Changes
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### Minor Changes
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
## 26.0.0
### Patch Changes
+216 -146
View File
File diff suppressed because it is too large. Load diff
+2217 -3
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "26.0.0",
"version": "27.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "26.0.0",
"@expo/eas-json": "26.0.0",
"@expo/eas-build-job": "27.1.0",
"@expo/eas-json": "27.1.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "26.0.0",
"@expo/logger": "27.1.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "26.0.0",
"@expo/steps": "27.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -198,6 +198,28 @@ export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunFragment(
status: WorkflowRunStatus,
overrides?: Partial<WorkflowRunFragment>
): WorkflowRunFragment {
return {
id: `run-${status}`,
status,
triggerEventType: WorkflowRunTriggerEventType.Manual,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
...overrides,
};
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
@@ -240,6 +262,7 @@ export function getMockWorkflowRunsFragment(
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
@@ -332,6 +355,7 @@ export function getMockWorkflowRunWithJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -364,6 +388,7 @@ export function getMockWorkflowRunWithBuildJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -0,0 +1,88 @@
import {
getMockWorkflowRunFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunCancel from '../../commands/workflow/cancel';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { promptAsync } from '../../prompts';
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../log');
jest.mock('../../prompts');
describe(WorkflowRunCancel, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('prompts with in-progress and queued runs and cancels the selected ones', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const inProgressRun = getMockWorkflowRunFragment(WorkflowRunStatus.InProgress);
const waitingRun = getMockWorkflowRunFragment(WorkflowRunStatus.Waiting);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockImplementation(async (_graphqlClient, _appId, status) =>
status === WorkflowRunStatus.InProgress ? [inProgressRun] : [waitingRun]
);
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedRuns: [waitingRun.id] });
const cmd = mockTestCommand(WorkflowRunCancel, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.InProgress,
50
);
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Waiting,
50
);
const promptCall = jest.mocked(promptAsync).mock.calls[0][0] as any;
expect(promptCall.choices.map((choice: any) => choice.value)).toEqual([
inProgressRun.id,
waitingRun.id,
]);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: waitingRun.id }
);
});
test('throws in non-interactive mode when no run IDs are given', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive'], ctx);
await expect(cmd.run()).rejects.toThrow(
'Must supply workflow run IDs as arguments when in non-interactive mode'
);
expect(promptAsync).not.toHaveBeenCalled();
});
test('cancels the given run IDs without prompting in non-interactive mode', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive', 'run-id-1'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: 'run-id-1' }
);
});
});
@@ -142,6 +142,7 @@ describe(WorkflowRunList, () => {
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
@@ -0,0 +1,83 @@
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createRequire } = require('node:module');
const root = fs.realpathSync(process.argv[2]);
const cli = path.join(root, 'packages/eas-cli');
const cliRequire = createRequire(path.join(cli, 'package.json'));
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
const { Config } = cliRequire('@oclif/core');
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
const EasHelp = require(path.join(cli, 'src/help')).default;
const cases = JSON.parse(process.argv[3]);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
const events = [];
globalThis.__helpLifecycleEvents = events;
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
EasCommand.prototype.run = async function () {
events.push({ event: 'run', id: this.id });
};
EasCommand.prototype.finally = async function () {};
(async () => {
try {
fs.writeFileSync(
path.join(directory, 'package.json'),
JSON.stringify({
name: 'eas-help-lifecycle-fixture',
version: '1.0.0',
oclif: {
bin: 'eas',
commands: { strategy: 'explicit', target: './commands.cjs' },
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
},
})
);
fs.writeFileSync(
path.join(directory, 'commands.cjs'),
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
);
for (const event of ['prerun', 'postrun']) {
fs.writeFileSync(
path.join(directory, `${event}.cjs`),
`module.exports = async function({Command}) { globalThis.__helpLifecycleEvents.push({event:${JSON.stringify(event)},id:Command.id}); };`
);
}
process.env.XDG_DATA_HOME = directory;
process.env.XDG_CACHE_HOME = directory;
const config = await Config.load({ root: directory });
const results = [];
for (const args of cases) {
// Published manifests discover metadata without loading the command class.
// Clear eager discovery's assignment to exercise that cold-start boundary.
delete Control.id;
const start = events.length;
const write = process.stdout.write;
let stdout = '',
error;
process.stdout.write = (chunk, encoding, callback) => {
stdout += String(chunk);
const done = typeof encoding === 'function' ? encoding : callback;
done?.();
return true;
};
try {
await new EasHelp(config).showHelp(args);
} catch (err) {
error = err.message;
} finally {
process.stdout.write = write;
}
results.push({ stdout, events: events.slice(start), error });
}
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});
@@ -0,0 +1,51 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const root = path.resolve(__dirname, '../../../..');
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
const actionHelp = [
['simulator:control', 'rotate', '--help'],
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
];
const incompleteFlags = [
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
];
let results: { stdout: string; events: unknown[]; error?: string }[];
beforeAll(async () => {
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
const { stdout } = await execFileAsync(
process.execPath,
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
{ timeout: 20_000 }
);
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
}, 30_000);
describe('simulator action help through real oclif lookup and hooks', () => {
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
'keeps canonical command identity and executes lifecycle hooks: $argv',
({ index }) => {
expect(results[index].error).toBeUndefined();
expect(results[index].events).toEqual([
{ event: 'prerun', id: 'simulator:control' },
{ event: 'run', id: 'simulator:control' },
{ event: 'postrun', id: 'simulator:control' },
]);
}
);
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
'shows ordinary help without starting the command for an incomplete flag: $argv',
({ index }) => {
const { stdout, events, error } = results[index + actionHelp.length];
expect(error).toBeUndefined();
expect(stdout).toContain('USAGE');
expect(events).toEqual([]);
}
);
});
+4 -3
View File
@@ -1,6 +1,5 @@
import { Config, Help } from '@oclif/core';
import SimulatorControl from '../commands/simulator/control';
import EasHelp from '../help';
describe(EasHelp, () => {
@@ -9,7 +8,7 @@ describe(EasHelp, () => {
let showHelp: jest.SpyInstance;
beforeEach(() => {
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
});
@@ -21,12 +20,14 @@ describe(EasHelp, () => {
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
])('routes action help through the command: %j', async (...argv) => {
await new EasHelp(config).showHelp(argv);
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
expect(showHelp).not.toHaveBeenCalled();
});
it.each([
['simulator:control', '--help'],
['simulator:control', 'screenshot.capture', '--help', '--output'],
['sim:control', 'rotate', '-h', '--id'],
['sim:control', '-h'],
['build', '--help'],
['simulator:control', 'clipboard.write', '--', '--help'],
@@ -1,5 +1,6 @@
import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
@@ -21,6 +22,7 @@ import {
EasBuildFreeTierLimitExceededError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
@@ -758,6 +760,38 @@ describe(Build.name, () => {
});
});
});
describe('for UNAUTHORIZED', () => {
it('points to the permission grant and permissions.account.role in eas.json', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError(
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role',
'UNAUTHORIZED'
);
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildUnauthorizedError>(
async () => {
await handleBuildRequestErrorAsync(error, platform);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildUnauthorizedError,
'Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role\n' +
`Request ID: ${mockRequestId}\n\n` +
`Run ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\n` +
`If your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
});
});
});
describe('error is unexpected graphQL-related error', () => {
+14
View File
@@ -25,6 +25,7 @@ import {
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
EasBuildUnauthorizedError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
@@ -47,6 +48,7 @@ import {
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { resolveJobExpoTokenRole } from './utils/expoTokenRole';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
LocalFile,
@@ -130,6 +132,7 @@ function resolveBuildParamsInput<T extends Platform>(
resourceClass: ctx.resourceClass,
sdkVersion: metadata.sdkVersion,
reactNativeVersion: metadata.reactNativeVersion,
expoTokenRole: resolveJobExpoTokenRole(ctx.buildProfile),
};
}
@@ -300,6 +303,17 @@ export async function handleBuildRequestErrorAsync(
throw new EasBuildTooManyPendingBuildsError(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
} else if (graphQLErrorCode === 'UNAUTHORIZED') {
const requestId = graphQLErrors[0]?.extensions?.requestId;
throw new EasBuildUnauthorizedError(
`${graphQLErrors[0]?.message}${requestId ? `\nRequest ID: ${requestId}` : ''}\n\nRun ${chalk.bold(
'eas whoami'
)} to check the user you are logged in as and your permission grant. Run ${chalk.bold(
'eas login'
)} to change the user or permission grant.\n\nIf your build profile in eas.json uses ${chalk.bold(
'permissions.account.role'
)}, it may mean your credentials do not grant the requested role.`
);
} else if (Array.isArray(error?.graphQLErrors)) {
const errorDetails = formatBuildRequestErrorDetails(error, graphQLErrors);
throw new Error(
+2
View File
@@ -22,4 +22,6 @@ export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
export class EasBuildUnauthorizedError extends EasCommandError {}
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
@@ -107,11 +107,7 @@ async function resolveEnvVarsAsync({
return { ...serverEnvVars, ...buildProfile.env };
} catch (e) {
Log.error(`Failed to pull env variables for environment ${environment} from EAS servers`);
Log.error(e);
Log.error(
'This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues'
);
return { ...buildProfile.env };
throw e;
}
}
@@ -0,0 +1,33 @@
import { Platform } from '@expo/eas-build-job';
import {
BuildProfile,
CredentialsSource,
JobExpoTokenRole as EasJsonJobExpoTokenRole,
} from '@expo/eas-json';
import { JobExpoTokenRole } from '../../../graphql/generated';
import { resolveJobExpoTokenRole } from '../expoTokenRole';
function createBuildProfile(
permissions?: BuildProfile<Platform.ANDROID>['permissions']
): BuildProfile<Platform.ANDROID> {
return {
credentialsSource: CredentialsSource.REMOTE,
distribution: 'store',
permissions,
};
}
describe(resolveJobExpoTokenRole, () => {
it('returns undefined when the build profile does not request a role', () => {
expect(resolveJobExpoTokenRole(createBuildProfile())).toBeUndefined();
});
it.each([
[EasJsonJobExpoTokenRole.RUNNER, JobExpoTokenRole.Runner],
[EasJsonJobExpoTokenRole.DEVELOPER, JobExpoTokenRole.Developer],
[EasJsonJobExpoTokenRole.RELEASE_MANAGER, JobExpoTokenRole.ReleaseManager],
])('maps the %s role to %s', (role, expected) => {
expect(resolveJobExpoTokenRole(createBuildProfile({ account: { role } }))).toBe(expected);
});
});
@@ -0,0 +1,20 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, JobExpoTokenRole as EasJsonJobExpoTokenRole } from '@expo/eas-json';
import { JobExpoTokenRole } from '../../graphql/generated';
const easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping: Record<
EasJsonJobExpoTokenRole,
JobExpoTokenRole
> = {
[EasJsonJobExpoTokenRole.RUNNER]: JobExpoTokenRole.Runner,
[EasJsonJobExpoTokenRole.DEVELOPER]: JobExpoTokenRole.Developer,
[EasJsonJobExpoTokenRole.RELEASE_MANAGER]: JobExpoTokenRole.ReleaseManager,
};
export function resolveJobExpoTokenRole<T extends Platform>(
profile: BuildProfile<T>
): JobExpoTokenRole | undefined {
const role = profile.permissions?.account.role;
return role ? easJsonJobExpoTokenRoleToJobExpoTokenRoleMapping[role] : undefined;
}
@@ -272,7 +272,7 @@ export default abstract class EasCommand extends Command {
let baseMessage = `${commandId} command failed.`;
if (err instanceof EasCommandError) {
if (!err.messageAlreadyPrinted) {
Log.error(err.message);
Log.errorToStderr(err.message);
}
} else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors
@@ -321,10 +321,10 @@ export default abstract class EasCommand extends Command {
const cleanMessage = err.networkError
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
: cleanGQLErrorsMessage;
Log.error(cleanMessage);
Log.errorToStderr(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
} else {
Log.error(err.message);
Log.errorToStderr(err.message);
}
Log.debug(err);
Sentry.withScope(scope => {
@@ -200,7 +200,7 @@ describe('EasCommand', () => {
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
@@ -218,7 +218,7 @@ describe('EasCommand', () => {
it('skips the message when the command already printed it', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
@@ -242,7 +242,7 @@ describe('EasCommand', () => {
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -262,7 +262,7 @@ describe('EasCommand', () => {
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -297,7 +297,7 @@ describe('EasCommand', () => {
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
@@ -11,8 +11,16 @@ jest.mock('fs');
jest.mock('../../../../prompts');
const originalStderrIsTTY = process.stderr.isTTY;
beforeEach(() => {
jest.resetAllMocks();
// The dependency notice prints once per process and only to a terminal, so keep it unspent.
process.stderr.isTTY = false;
});
afterAll(() => {
process.stderr.isTTY = originalStderrIsTTY;
});
describe(findProjectRootAsync, () => {
@@ -115,3 +123,32 @@ describe(findProjectDirAndVerifyProjectSetupAsync, () => {
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
});
describe('eas-cli dependency notice', () => {
beforeEach(() => {
vol.reset();
vol.fromJSON(
{
'./eas.json': JSON.stringify({}),
'./package.json': JSON.stringify({ devDependencies: { 'eas-cli': '*' } }),
},
'/app'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('prints the notice only when stderr is a terminal', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
process.stderr.isTTY = false;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).not.toHaveBeenCalled();
process.stderr.isTTY = true;
await findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('in your project dependencies'));
});
});
@@ -127,7 +127,8 @@ export async function findProjectDirAndVerifyProjectSetupAsync({
} = {}): Promise<string> {
const projectDir = await findProjectRootAsync({ cwd });
await applyCliConfigAsync(projectDir);
if (!ranEnsureEasCliIsNotInDependencies) {
// The notice is advice for a person. Programs that run eas-cli read stderr for its errors.
if (!ranEnsureEasCliIsNotInDependencies && process.stderr.isTTY) {
ranEnsureEasCliIsNotInDependencies = true;
await ensureEasCliIsNotInDependenciesAsync(projectDir);
}
@@ -0,0 +1,137 @@
import { vol } from 'memfs';
import { collectReferencedWorkflowFilesAsync } from '../referencedFiles';
jest.mock('fs');
const projectDir = '/app';
const workflowsDir = `${projectDir}/.eas/workflows`;
function callerYaml(calls: Record<string, string>): string {
return [
'jobs:',
...Object.entries(calls).flatMap(([jobKey, fileName]) => [
` ${jobKey}:`,
` uses: ./.eas/workflows/${fileName}`,
]),
'',
].join('\n');
}
const calleeYaml = [
'on:',
' workflow_call: {}',
'jobs:',
' job:',
' steps:',
' - run: echo hi',
'',
].join('\n');
async function collectAsync(
rootYamlConfig: string
): Promise<{ fileName: string; yamlConfig: string }[]> {
return await collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName: 'root.yml',
rootYamlConfig,
});
}
describe(collectReferencedWorkflowFilesAsync, () => {
beforeEach(() => {
vol.reset();
});
it('returns no files and reads nothing when the root has no uses jobs', async () => {
vol.fromJSON({ [`${workflowsDir}/unused.yml`]: 'jobs: [not: valid' });
await expect(collectAsync(calleeYaml)).resolves.toEqual([]);
});
it('returns no files when the root is not valid YAML', async () => {
await expect(collectAsync('jobs: [not: valid')).resolves.toEqual([]);
});
it('collects a file called from one job', async () => {
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).resolves.toEqual([
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
it('collects nested calls in discovery order', async () => {
const releaseYaml = `on:\n workflow_call: {}\n${callerYaml({ build: 'build.yml' })}`;
vol.fromJSON({
[`${workflowsDir}/release.yml`]: releaseYaml,
[`${workflowsDir}/build.yaml`]: calleeYaml,
[`${workflowsDir}/build.yml`]: calleeYaml,
});
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).resolves.toEqual([
{ fileName: 'release.yml', yamlConfig: releaseYaml },
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
it('collects each file once and stops at cycles', async () => {
const aYaml = callerYaml({ b: 'b.yml' });
const bYaml = callerYaml({ a: 'a.yml', root: 'root.yml', c: 'c.yml' });
vol.fromJSON({
[`${workflowsDir}/a.yml`]: aYaml,
[`${workflowsDir}/b.yml`]: bYaml,
[`${workflowsDir}/c.yml`]: calleeYaml,
});
await expect(
collectAsync(callerYaml({ first: 'a.yml', second: 'c.yml', third: 'a.yml' }))
).resolves.toEqual([
{ fileName: 'a.yml', yamlConfig: aYaml },
{ fileName: 'c.yml', yamlConfig: calleeYaml },
{ fileName: 'b.yml', yamlConfig: bYaml },
]);
});
it('fails when a called file does not exist', async () => {
await expect(collectAsync(callerYaml({ ship: 'missing.yml' }))).rejects.toThrow(
'Job "ship" in root.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
);
});
it('fails when a nested called file does not exist', async () => {
vol.fromJSON({ [`${workflowsDir}/release.yml`]: callerYaml({ build: 'missing.yml' }) });
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).rejects.toThrow(
'Job "build" in release.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
);
});
it('fails when a called file is not valid YAML', async () => {
vol.fromJSON({ [`${workflowsDir}/build.yml`]: 'jobs: [not: valid' });
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).rejects.toThrow(
/^Job "ship" in root\.yml uses \.\/\.eas\/workflows\/build\.yml, but \/app\/\.eas\/workflows\/build\.yml is not valid YAML: /
);
});
it('skips uses values the server does not accept', async () => {
const rootYaml = [
'jobs:',
' nested:',
' uses: ./.eas/workflows/nested/build.yml',
' interpolated:',
' uses: ./.eas/workflows/${{ inputs.name }}.yml',
' notString:',
' uses: { path: ./.eas/workflows/build.yml }',
' valid:',
' uses: ./.eas/workflows/build.yml',
'',
].join('\n');
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
await expect(collectAsync(rootYaml)).resolves.toEqual([
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
});
@@ -1,8 +1,11 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import {
getMockWorkflowRunFragment,
getMockWorkflowRunWithJobsFragment,
} from '../../../__tests__/commands/utils';
import { WorkflowJobStatus, WorkflowRunStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
import { JobLogs, RawLogLine } from '../../logs/types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
import { formatActiveWorkflowRun, formatFailedWorkflowRun, processWorkflowRuns } from '../utils';
function jobWithLogs(
logLines: RawLogLine[],
@@ -135,3 +138,32 @@ describe(formatFailedWorkflowRun, () => {
expect(output).toContain('line7');
});
});
describe(processWorkflowRuns, () => {
test.each([WorkflowRunStatus.Success, WorkflowRunStatus.Failure, WorkflowRunStatus.Canceled])(
'reports finalizedAt as finishedAt for final status %s',
status => {
const [run] = processWorkflowRuns([
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
]);
expect(run.finishedAt).toBe('2022-01-01T12:00:00.000Z');
}
);
test('reports no finishedAt for a final run predating finalizedAt', () => {
const [run] = processWorkflowRuns([getMockWorkflowRunFragment(WorkflowRunStatus.Success)]);
expect(run.finishedAt).toBeNull();
});
test.each([
WorkflowRunStatus.New,
WorkflowRunStatus.Waiting,
WorkflowRunStatus.InProgress,
WorkflowRunStatus.ActionRequired,
])('reports no finishedAt for non-final status %s even with a lingering finalizedAt', status => {
const [run] = processWorkflowRuns([
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
]);
expect(run.finishedAt).toBeNull();
});
});
@@ -0,0 +1,83 @@
import fs from 'fs';
import path from 'path';
import { parsedYamlFromWorkflowContents } from './parse';
import { WorkflowReferencedFileInput } from '../../graphql/generated';
const WORKFLOW_CALL_USES_REGEX = /^\.\/\.eas\/workflows\/(?:(?!\$\{\{)[^/\\])+\.ya?ml$/;
type WorkflowCall = { callerFileName: string; callerJobKey: string; uses: string };
export async function collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName,
rootYamlConfig,
}: {
projectDir: string;
rootFileName: string;
rootYamlConfig: string;
}): Promise<WorkflowReferencedFileInput[]> {
let rootYaml: unknown;
try {
rootYaml = parsedYamlFromWorkflowContents({ yamlConfig: rootYamlConfig });
} catch {
return [];
}
const collectedFileNames = new Set<string>([rootFileName]);
const referencedFiles: WorkflowReferencedFileInput[] = [];
const pendingCalls = getWorkflowCalls(rootFileName, rootYaml);
for (const call of pendingCalls) {
const fileName = path.posix.basename(call.uses);
if (collectedFileNames.has(fileName)) {
continue;
}
collectedFileNames.add(fileName);
const { yamlConfig, parsedYaml } = await readReferencedWorkflowFileAsync(
projectDir,
fileName,
call
);
referencedFiles.push({ fileName, yamlConfig });
pendingCalls.push(...getWorkflowCalls(fileName, parsedYaml));
}
return referencedFiles;
}
function getWorkflowCalls(callerFileName: string, parsedYaml: unknown): WorkflowCall[] {
const jobs = (parsedYaml as { jobs?: unknown } | null)?.jobs;
if (!jobs || typeof jobs !== 'object') {
return [];
}
return Object.entries(jobs).flatMap(([callerJobKey, job]) => {
const uses = (job as { uses?: unknown } | null)?.uses;
return typeof uses === 'string' && WORKFLOW_CALL_USES_REGEX.test(uses)
? [{ callerFileName, callerJobKey, uses }]
: [];
});
}
async function readReferencedWorkflowFileAsync(
projectDir: string,
fileName: string,
{ callerFileName, callerJobKey, uses }: WorkflowCall
): Promise<{ yamlConfig: string; parsedYaml: unknown }> {
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
const describeCall = `Job "${callerJobKey}" in ${callerFileName} uses ${uses}, but ${filePath}`;
let yamlConfig: string;
try {
yamlConfig = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`${describeCall} does not exist.`);
}
throw new Error(`${describeCall} could not be read: ${(error as Error).message}`);
}
try {
return { yamlConfig, parsedYaml: parsedYamlFromWorkflowContents({ yamlConfig }) };
} catch (error) {
throw new Error(`${describeCall} is not valid YAML: ${(error as Error).message}`);
}
}
@@ -19,7 +19,7 @@ export type WorkflowRunResult = {
triggerType: WorkflowTriggerType;
trigger: string | null;
startedAt: string;
finishedAt: string;
finishedAt: string | null;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
import { getWorkflowRunUrl } from '../../build/utils/url';
import {
WorkflowJobStatus,
WorkflowRunByIdQuery,
@@ -15,7 +16,7 @@ import { choicesFromJobLogs, stepLogTail } from '../logs/format';
import { JobLogs } from '../logs/types';
import { LogsWatcher } from '../logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import Log, { link } from '../../log';
import { ora, updateSpinnerText } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
@@ -81,7 +82,7 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
const finishedAt = isWorkflowRunFinal(run.status) ? (run.finalizedAt ?? null) : null;
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
return {
id: run.id,
@@ -135,6 +136,20 @@ function isJobCompleted(status: WorkflowJobStatus): boolean {
}
}
function isWorkflowRunFinal(status: WorkflowRunStatus): boolean {
switch (status) {
case WorkflowRunStatus.Success:
case WorkflowRunStatus.Failure:
case WorkflowRunStatus.Canceled:
return true;
case WorkflowRunStatus.New:
case WorkflowRunStatus.Waiting:
case WorkflowRunStatus.InProgress:
case WorkflowRunStatus.ActionRequired:
return false;
}
}
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
@@ -255,13 +270,12 @@ export async function showWorkflowStatusAsync(
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const waitingToStartPrefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
const spinner = ora({
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
const watcher = new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
@@ -283,7 +297,27 @@ export async function showWorkflowStatusAsync(
switch (workflowRun.status) {
case WorkflowRunStatus.New:
// A queued run returns to NEW once its concurrency group frees up.
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
break;
case WorkflowRunStatus.Waiting: {
const {
blockingWorkflowRun,
workflow: { app },
} = workflowRun;
// The blocking run is in the same app: concurrency groups are app-scoped.
const actionRequiredSuffix =
blockingWorkflowRun?.status === WorkflowRunStatus.ActionRequired
? ` — it needs your action first: ${link(getWorkflowRunUrl(app.ownerAccount.name, app.slug, blockingWorkflowRun.id))}`
: '';
const reason = blockingWorkflowRun
? `Waiting for ${blockingWorkflowRun.name}${actionRequiredSuffix}`
: 'Waiting for its concurrency group';
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is queued:} ${reason}`,
});
break;
}
case WorkflowRunStatus.InProgress: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
@@ -1,7 +1,12 @@
import chalk from 'chalk';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { getMockViewerUserPermission } from '../../../__tests__/viewerUserPermission';
import { Permission, Role } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor } from '../../../user/User';
import { resumeDeviceLoginAsync, startDeviceLoginAsync } from '../../../user/deviceLogin';
import AccountLogin from '../login';
@@ -33,12 +38,12 @@ const sessionManager = new SessionManager({
const originalIsTTY = process.stdin.isTTY;
const originalCI = process.env.CI;
function command(args: string[]): AccountLogin {
function command(args: string[], actor: Actor | null = null): AccountLogin {
const instance = new AccountLogin(['--device', ...args], getMockOclifConfig());
// @ts-expect-error Mock the protected context without initializing a real session.
jest.spyOn(instance, 'getContextAsync').mockResolvedValue({
sessionManager,
maybeLoggedIn: { actor: null },
maybeLoggedIn: { actor },
});
return instance;
}
@@ -179,3 +184,48 @@ it('requires device login for non-interactive sign-in', async () => {
'Use eas login --device --non-interactive to log in without prompts.'
);
});
describe('when already logged in', () => {
const ownerPermissions = getMockViewerUserPermission(Role.Owner).permissions;
const limitedMessage =
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.';
function userActor(
effectivePermissions: Actor['accounts'][number]['viewerUserPermission2']['effectivePermissions']
): Actor {
return {
__typename: 'User',
id: 'user-id',
email: '[email protected]',
username: 'jane',
accounts: [
{
id: 'jane-id',
name: 'jane',
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
},
],
isExpoAdmin: false,
featureGates: {},
};
}
beforeEach(() => {
jest.mocked(startDeviceLoginAsync).mockResolvedValue(pending);
});
it('shows the roles that the current credentials limit', async () => {
await command(['--non-interactive'], userActor([Permission.View])).runAsync();
expect(Log.warn).toHaveBeenCalledWith(`You are already logged in as ${chalk.bold('jane')}.`);
expect(Log.log).toHaveBeenCalledWith(
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
);
expect(Log.log).toHaveBeenCalledWith(limitedMessage);
});
it('does not list accounts when the credentials do not limit them', async () => {
await command(['--non-interactive'], userActor(ownerPermissions)).runAsync();
expect(Log.log).not.toHaveBeenCalledWith('Accounts:');
expect(Log.log).not.toHaveBeenCalledWith(limitedMessage);
});
});
@@ -7,6 +7,7 @@ import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
import {
isDeviceLoginFailure,
resumeDeviceLoginAsync,
@@ -77,6 +78,13 @@ export default class AccountLogin extends EasCommand {
if (actor && !resume) {
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
Log.newLine();
Log.log(
'The permissions granted during authentication limit your access to some accounts. Log in again and change the permission grant.'
);
}
const shouldContinue =
nonInteractive ||
+9 -87
View File
@@ -1,10 +1,9 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { Permission, Role } from '../../graphql/generated';
import Log from '../../log';
import { Actor, getActorDisplayName } from '../../user/User';
import { getRoleForPermissions } from '../../user/roles';
import { getActorDisplayName } from '../../user/User';
import { logAccountPermissions } from '../../user/accountPermissions';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
@@ -28,95 +27,18 @@ export default class AccountView extends EasCommand {
Log.log(actor.email);
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more, or if the credentials used for
// this command limit the permissions on it
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
const hasLimitedAccount = actor.accounts.some(AccountView.isLimitedByCredentials);
if (accountExcludingPersonalAccount.length > 0 || hasLimitedAccount) {
const { hasLimitedAccount } = logAccountPermissions(actor);
if (hasLimitedAccount) {
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = AccountView.getLabelForPermissions(permissions);
if (AccountView.isLimitedByCredentials(account)) {
const effectiveRoleLabel = AccountView.getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
if (hasLimitedAccount) {
Log.newLine();
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
}
Log.log(
`The permissions granted during authentication limit your access to some accounts. Run ${chalk.bold(
'eas login'
)} to change the permission grant.`
);
}
} else {
Log.warn('Not logged in');
process.exit(1);
}
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
private static isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
private static getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return AccountView.getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
private static getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ReleaseManager:
return 'Release Manager';
case Role.Runner:
return 'Runner';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
// A role added to the API after this version of the CLI was published. TypeScript narrows
// `role` to `never` here, but the server can still send one, so render its name rather than
// returning undefined.
default: {
const unknownRole: string = role;
return unknownRole
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
}
}
}
@@ -44,6 +44,10 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../utils/json');
jest.mock('fs-extra', () => {
const actual = jest.requireActual<typeof import('fs-extra')>('fs-extra');
return { ...actual, stat: jest.fn(actual.stat) };
});
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
@@ -236,12 +240,23 @@ describe(SimulatorControl, () => {
expect(close).toHaveBeenCalledTimes(1);
});
it('explains local and explicit host upload paths without running an action', async () => {
await createCommand(['media.add', '--help', '--id', 'session-123']).runAsync();
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' File paths are local. Use host:/absolute/path for files on the simulator host.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});
it('prints the host action schema as JSON for help', async () => {
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
action: 'app.install',
...catalog['app.install'],
description: expect.stringContaining('host:/absolute/path'),
});
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
@@ -328,6 +343,39 @@ describe(SimulatorControl, () => {
]);
});
it('rejects host paths for ID-only uploads and does not advertise them', async () => {
served = {
...catalog,
'app.file.upload': {
type: 'object',
properties: { uploadId: { type: 'string' } },
upload: 'uploadId',
uploadAs: 'id',
},
};
await expect(
createCommand(['app.file.upload', 'host:/tmp/a.json', '--id', 'session-123']).runAsync()
).rejects.toThrow('requires a local file');
expect(actionRequests()).toEqual([]);
await createCommand(['app.file.upload', '--help', '--id', 'session-123']).runAsync();
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
expect(lines.join('\n')).not.toContain('host:/absolute/path');
expect(lines.join('\n')).toContain('requires a local file');
await createCommand(['app.file.upload', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenLastCalledWith(
expect.objectContaining({
description: expect.stringContaining('requires a local file'),
})
);
});
it('explains how to upload a local filename beginning with host:', async () => {
await expect(
createCommand(['media.add', 'host:photo.png', '--id', 'session-123']).runAsync()
).rejects.toThrow('./host:photo.png');
expect(actionRequests()).toEqual([]);
});
it('keeps an upload that the action goes on reading', async () => {
const localFile = path.join(tmpDir, 'feed.mp4');
await fs.writeFile(localFile, 'video bytes');
@@ -395,17 +443,65 @@ describe(SimulatorControl, () => {
expect(actionRequests()).toEqual([]);
});
it('passes a path that is not a local file through unchanged', async () => {
actionResult = ok('');
it('refuses a missing absolute local file without running an action', async () => {
const missing = path.join(tmpDir, 'missing.png');
await expect(
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
).rejects.toThrow('does not exist on this computer');
expect(actionRequests()).toEqual([]);
});
await createCommand([
'app.install',
'/Users/runner/MyApp.ipa',
'--id',
'session-123',
]).runAsync();
it('preserves a local filesystem failure without sending an action', async () => {
const denied = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
const stat = jest
.mocked<(path: string) => Promise<fs.Stats>>(fs.stat)
.mockRejectedValueOnce(denied);
try {
await expect(
createCommand([
'media.add',
path.join(tmpDir, 'private.png'),
'--id',
'session-123',
]).runAsync()
).rejects.toBe(denied);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
} finally {
stat
.mockReset()
.mockImplementation(jest.requireActual<typeof import('fs-extra')>('fs-extra').stat);
}
});
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
it('passes an explicit host path through without uploading a matching local file', async () => {
const localFile = path.join(tmpDir, 'existing.png');
await fs.writeFile(localFile, 'local bytes');
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
});
it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
await expect(
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
).rejects.toThrow('absolute');
expect(actionRequests()).toEqual([]);
});
it('uploads a local filename beginning with host: when explicitly relative', async () => {
const previous = process.cwd();
await fs.writeFile(path.join(tmpDir, 'host:photo.png'), 'png bytes');
try {
process.chdir(tmpDir);
await createCommand(['media.add', './host:photo.png', '--id', 'session-123']).runAsync();
expect(actionRequests().map(([action]) => action)).toEqual([
'upload.append',
'media.add',
'upload.remove',
]);
} finally {
process.chdir(previous);
}
});
it('downloads the file an action returns', async () => {
@@ -443,7 +539,14 @@ describe(SimulatorControl, () => {
it('lists the actions as JSON when no action is provided', async () => {
await createCommand(['--id', 'session-123', '--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
'app.install': expect.objectContaining({
description: expect.stringContaining('host:/absolute/path'),
}),
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
})
);
expect(actionRequests()).toEqual([]);
});
@@ -0,0 +1,501 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform, DeviceRunSessionStatus } from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { downloadSimulatorArtifactAsync } from '../../../simulator/artifacts';
import Log from '../../../log';
import { loadSimulatorEnvAsync } from '../../../simulator/env';
import {
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../../../simulator/preview';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorLogs from '../logs';
jest.mock('../../../log');
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../simulator/artifacts', () => ({
...jest.requireActual('../../../simulator/artifacts'),
downloadSimulatorArtifactAsync: jest.fn(),
}));
jest.mock('../../../simulator/env', () => ({
...jest.requireActual('../../../simulator/env'),
loadSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/preview', () => ({
...jest.requireActual('../../../simulator/preview'),
resolveSimulatorPreviewAsync: jest.fn(),
fetchSimulatorPreviewJsonAsync: jest.fn(),
streamSimulatorPreviewAsync: jest.fn(),
}));
jest.mock('../../../utils/json');
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResolvePreviewAsync = jest.mocked(resolveSimulatorPreviewAsync);
const mockFetchJsonAsync = jest.mocked(fetchSimulatorPreviewJsonAsync);
const mockStreamAsync = jest.mocked(streamSimulatorPreviewAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const mockLog = jest.mocked(Log.log);
const mockWarn = jest.mocked(Log.warn);
const mockWarnToStderr = jest.mocked(Log.warnToStderr);
const originalColorLevel = chalk.level;
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'preview-token',
};
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project';
const line = {
seq: 7,
at: Date.parse('2026-10-05T12:00:00.000Z'),
raw: JSON.stringify({
timestamp: '2026-10-05T12:00:00.000Z',
processImagePath: '/apps/CoinFlip.app/CoinFlip',
processID: 42,
eventMessage: 'Initialization request completed.',
}),
};
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({ failures: [], successes: [] });
return config;
}
describe(SimulatorLogs, () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.EAS_SIMULATOR_SESSION_ID = 'session-id';
chalk.level = 0;
mockLoadSimulatorEnvAsync.mockResolvedValue();
jest
.mocked(DeviceRunSessionQuery.byIdAsync)
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.InProgress } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>);
mockResolvePreviewAsync.mockResolvedValue(preview);
mockFetchJsonAsync.mockResolvedValue(createSnapshot());
});
afterEach(() => {
delete process.env.EAS_SIMULATOR_SESSION_ID;
chalk.level = originalColorLevel;
});
it.each(['Error', 16, 'Fault', 17, 'Debug', 2])(
'styles OS severity %s after removing remote terminal controls',
async messageType => {
chalk.level = 1;
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([
{
...line,
raw: JSON.stringify({
...JSON.parse(line.raw),
messageType,
processImagePath: '/apps/\u001b[35mCoinFlip\u001b[0m',
eventMessage: '\u001b[35mInitialization request completed.\u001b[0m\u0007',
}),
},
])
);
await createCommand(['--timestamp']).runAsync();
const output = mockLog.mock.calls[0][0];
expect(output).toContain('\u001b[36m[CoinFlip:42]\u001b[39m');
expect(output).not.toContain('\u001b[35m');
expect(output).not.toContain('\u0007');
const message = 'Initialization request completed.';
const styledMessage =
messageType === 'Debug' || messageType === 2
? chalk.dim(message)
: messageType === 'Fault' || messageType === 17
? chalk.red.bold(message)
: chalk.red(message);
expect(output).toContain(styledMessage);
}
);
it('prints the full JSON snapshot for an explicit session without starting the log source', async () => {
const snapshot = createSnapshot([line]);
mockFetchJsonAsync.mockResolvedValue(snapshot);
const command = createCommand(['--id', 'session-id', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-id');
expect(mockResolvePreviewAsync).toHaveBeenCalledWith(
expect.objectContaining({ id: 'session-id' })
);
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'user-apps', limit: '100', snapshot: '1' },
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
...snapshot,
});
expect(mockLog).not.toHaveBeenCalled();
expect(mockWarn).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
});
it('uses the dotenv session ID when no ID is provided', async () => {
mockLoadSimulatorEnvAsync.mockImplementation(async () => {
process.env.EAS_SIMULATOR_SESSION_ID = 'dotenv-id';
});
await createCommand(['--json']).runAsync();
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-id');
});
it('formats app messages and forwards scope and buffer limit', async () => {
mockFetchJsonAsync.mockResolvedValue(createSnapshot([line]));
await createCommand(['--scope', 'all', '--limit', '3']).runAsync();
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'all', limit: '3', snapshot: '1' },
});
expect(mockLog).toHaveBeenCalledWith('[CoinFlip:42] Initialization request completed.');
});
it('shows original timestamps for parsed and unparsed snapshot lines when requested', async () => {
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([line, { ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }])
);
await createCommand(['--timestamp']).runAsync();
expect(mockLog).toHaveBeenNthCalledWith(
1,
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:00.000Z raw message');
});
it('prints unparsed lines safely and reports an unhealthy log source', async () => {
mockFetchJsonAsync.mockResolvedValue({
...createSnapshot([{ ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }]),
streamError: '\u001b[31mlog source restarting\u001b[0m\u0007',
});
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith('raw message');
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
});
it('reports an unhealthy log source without claiming the buffer is simply empty', async () => {
mockFetchJsonAsync.mockResolvedValue({
...createSnapshot(),
streamError: 'log source restarting',
});
await createCommand([]).runAsync();
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
expect(mockLog).not.toHaveBeenCalled();
});
it('keeps the log source error in JSON output instead of warning', async () => {
const snapshot = { ...createSnapshot(), streamError: 'log source restarting' };
mockFetchJsonAsync.mockResolvedValue(snapshot);
await createCommand(['--json']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
...snapshot,
});
expect(mockWarn).not.toHaveBeenCalled();
});
it('uses the current time for a snapshot line with an invalid time', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-05T13:00:00.000Z'));
mockFetchJsonAsync.mockResolvedValue(
createSnapshot([line, { ...line, at: 1e300, raw: 'raw message' }])
);
await createCommand(['--timestamp']).runAsync();
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T13:00:00.000Z raw message');
now.mockRestore();
});
it('explains that an empty snapshot needs following before actions', async () => {
await createCommand([]).runAsync();
expect(mockLog).toHaveBeenCalledWith(
'No buffered logs. Use --follow to collect logs before performing actions.'
);
});
it('formats replayed and new raw SSE logs without requiring envelopes', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(line.raw);
onData('\u001b[31mnew message\u001b[0m\u0007');
});
await createCommand(['--id', 'session-id', '--follow']).runAsync();
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
query: { scope: 'user-apps', limit: '100' },
signal: expect.any(AbortSignal),
});
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenNthCalledWith(1, '[CoinFlip:42] Initialization request completed.');
expect(mockLog).toHaveBeenLastCalledWith('new message');
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('uses server envelopes for accurate timestamps while following unparsed logs', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(JSON.stringify(line));
onData(JSON.stringify({ ...line, at: line.at + 1_000, raw: 'new message' }));
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
query: { scope: 'user-apps', limit: '100', envelope: '1' },
signal: expect.any(AbortSignal),
});
expect(mockLog).toHaveBeenNthCalledWith(
1,
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:01.000Z new message');
});
it('prints a frame that is not an envelope instead of ending the stream', async () => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData('\u001b[31mnot an envelope\u001b[0m');
onData(JSON.stringify({ seq: 8 }));
onData(JSON.stringify(line));
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockLog).toHaveBeenCalledTimes(3);
expect(mockLog).toHaveBeenNthCalledWith(1, 'not an envelope');
expect(mockLog).toHaveBeenNthCalledWith(2, '{"seq":8}');
expect(mockLog).toHaveBeenLastCalledWith(
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
);
});
it.each([
JSON.stringify({ ...line, at: 'not-a-number' }),
JSON.stringify({ ...line, at: null }),
JSON.stringify(line).replace(String(line.at), '1e400'),
JSON.stringify({ ...line, at: 1e300 }),
])('prints an envelope with an invalid time instead of ending the stream %#', async frame => {
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
onData(frame);
});
await createCommand(['--follow', '--timestamp']).runAsync();
expect(mockLog).toHaveBeenCalledWith(frame);
});
it('reports a failed log source while its stream only sends heartbeats', async () => {
jest.useFakeTimers();
try {
mockFetchJsonAsync
.mockResolvedValueOnce({
...createSnapshot(),
status: 'restarting',
streamError: '\u001b[31mlog stream exited (code 1, signal null)\u001b[0m',
})
.mockResolvedValueOnce({
...createSnapshot(),
status: 'restarting',
streamError: 'log stream exited (code 1, signal null)',
})
.mockResolvedValueOnce(createSnapshot());
// A connected stream that only receives heartbeats never calls onData.
mockStreamAsync.mockImplementation(async () => {
await jest.advanceTimersByTimeAsync(30_000);
});
await createCommand(['--follow', '--scope', 'all']).runAsync();
expect(mockFetchJsonAsync).toHaveBeenCalledTimes(3);
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
query: { scope: 'all', limit: '1', snapshot: '1' },
});
// Both notices go to stderr, so `--follow > logs.txt` keeps only log lines.
expect(mockWarnToStderr.mock.calls).toEqual([
[
"Simulator logs are not being collected. The session's log source stopped: log stream exited (code 1, signal null). It restarts automatically, and logs from this gap are missing.",
],
['Simulator log collection resumed.'],
]);
expect(mockWarn).not.toHaveBeenCalled();
expect(mockLog).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('stops checking the log source when following stops', async () => {
jest.useFakeTimers();
try {
mockStreamAsync.mockResolvedValue();
await createCommand(['--follow']).runAsync();
await jest.advanceTimersByTimeAsync(30_000);
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('stops following on Ctrl+C and removes its interrupt handler', async () => {
const listeners = process.listeners('SIGINT');
let signal: AbortSignal | undefined;
mockStreamAsync.mockImplementation(async (_preview, _path, _onData, options) => {
signal = options?.signal;
process.emit('SIGINT');
});
await createCommand(['--follow']).runAsync();
expect(signal?.aborted).toBe(true);
expect(process.listeners('SIGINT')).toEqual(listeners);
});
it('rejects JSON follow output before resolving a session', async () => {
await expect(createCommand(['--json', '--follow']).runAsync()).rejects.toThrow(
'--json=true cannot also be provided when using --follow'
);
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockLoadSimulatorEnvAsync).not.toHaveBeenCalled();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
'downloads logs for a %s session without accessing preview',
async status => {
const session = { id: 'session-id', status } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>;
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue(session);
await createCommand(['--artifact', '2', '--output', 'logs.ndjson', '--json']).runAsync();
expect(downloadSimulatorArtifactAsync).toHaveBeenCalledWith(session, 'simulator-log', {
artifact: 2,
output: 'logs.ndjson',
nonInteractive: true,
json: true,
});
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledTimes(1);
}
);
it('rejects following a stopped session with a download action', async () => {
jest
.mocked(DeviceRunSessionQuery.byIdAsync)
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.Stopped } as Awaited<
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
>);
await expect(createCommand(['--follow']).runAsync()).rejects.toThrow('Use --output <path>');
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it.each([
['--artifact', '1'],
['--output', 'logs.ndjson'],
])('rejects artifact flags on a running session %s', async (...args) => {
await expect(createCommand(args).runAsync()).rejects.toThrow(
'only available for stopped sessions'
);
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
});
it.each([[], ['--follow'], ['--json']])(
'explains that a live Android session has no logs %s',
async (...args) => {
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue({
id: 'session-id',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Android,
} as Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>);
await expect(createCommand(args).runAsync()).rejects.toThrow(
'Live logs are only available for iOS simulator sessions.'
);
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
}
);
it.each(['0', '-1'])('rejects a nonpositive buffer limit %s', async limit => {
await expect(createCommand(['--limit', limit]).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it('rejects invalid scopes', async () => {
await expect(createCommand(['--scope', 'system']).runAsync()).rejects.toThrow();
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
});
it('stops before contacting the preview when there is no session', async () => {
delete process.env.EAS_SIMULATOR_SESSION_ID;
await expect(createCommand([]).runAsync()).rejects.toThrow('No simulator session ID provided.');
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
expect(mockStreamAsync).not.toHaveBeenCalled();
});
it('propagates preview errors without printing an empty result', async () => {
mockFetchJsonAsync.mockRejectedValue(new Error('The simulator preview refused access.'));
await expect(createCommand([]).runAsync()).rejects.toThrow(
'The simulator preview refused access.'
);
expect(mockLog).not.toHaveBeenCalled();
});
});
function createCommand(args: string[]): SimulatorLogs {
const command = new SimulatorLogs(args, getMockOclifConfig());
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
projectDir,
});
return command;
}
function createSnapshot(lines: (typeof line)[] = []): object {
return {
device: 'device-id',
latestSeq: 7,
oldestSeq: 1,
bufferedBytes: 200,
status: 'streaming',
streamError: null,
lines,
};
}
@@ -148,7 +148,11 @@ export default class SimulatorControl extends EasCommand {
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput(catalog);
printJsonOnlyOutput(
Object.fromEntries(
Object.entries(catalog).map(([name, entry]) => [name, withUploadPathHelp(entry)])
)
);
return;
}
const names = Object.keys(catalog).sort();
@@ -167,7 +171,7 @@ export default class SimulatorControl extends EasCommand {
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput({ ...entry, action });
printJsonOnlyOutput({ ...withUploadPathHelp(entry), action });
return;
}
Log.newLine();
@@ -198,6 +202,11 @@ export default class SimulatorControl extends EasCommand {
' Action arguments refer to the simulator host. Use --output for the local filename.'
);
}
const uploadHelp = uploadPathHelp(entry);
if (uploadHelp) {
Log.newLine();
Log.log(` ${uploadHelp}`);
}
Log.newLine();
}
@@ -270,8 +279,24 @@ export default class SimulatorControl extends EasCommand {
}
}
function uploadPathHelp(entry: HostActionCatalogEntry): string | undefined {
if (!entry.upload) {
return undefined;
}
return entry.uploadAs === 'id'
? 'This action requires a local file to upload. host: paths are not supported.'
: 'File paths are local. Use host:/absolute/path for files on the simulator host.';
}
function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEntry {
const help = uploadPathHelp(entry);
return help
? { ...entry, description: [entry.description, help].filter(Boolean).join('\n\n') }
: entry;
}
async function findLocalUploadAsync(
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
params: Record<string, unknown>
): Promise<{ param: string; path: string } | null> {
const value = param ? params[param] : undefined;
@@ -279,13 +304,33 @@ async function findLocalUploadAsync(
if (!param || typeof value !== 'string' || !applies) {
return null;
}
const stat = await fs.stat(value).catch(() => null);
if (value.startsWith('host:')) {
if (uploadAs === 'id') {
throw new Error(
'This action requires a local file to upload; host: paths are not supported.'
);
}
const hostPath = value.slice('host:'.length);
if (!path.posix.isAbsolute(hostPath)) {
throw new Error(
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
);
}
params[param] = hostPath;
return null;
}
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return null;
}
throw error;
});
if (stat?.isDirectory()) {
throw new Error(`${value} is a folder. Pass the path of a file.`);
}
if (!stat && !path.isAbsolute(value)) {
if (!stat) {
throw new Error(
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
);
}
return stat ? { param, path: value } : null;
@@ -0,0 +1,251 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppPlatform } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log from '../../log';
import { downloadSimulatorArtifactAsync, hasSimulatorArtifacts } from '../../simulator/artifacts';
import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
} from '../../simulator/env';
import {
type SimulatorPreview,
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../../simulator/preview';
import {
formatLogLine,
isValidLogTime,
stripTerminalControlCharacters,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// serve-sim keeps sending heartbeats while its log source restarts, so the stream alone cannot show a failure.
const LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS = 10_000;
interface SimulatorLogLine {
seq: number;
at: number;
raw: string;
}
interface SimulatorLogsSnapshot {
device: string;
latestSeq: number;
oldestSeq: number;
bufferedBytes: number;
status: string;
streamError: string | null;
lines: SimulatorLogLine[];
}
export default class SimulatorLogs extends EasCommand {
static override aliases = ['sim:logs'];
static override description =
'[EXPERIMENTAL] show buffered logs from a remote iOS simulator session';
static override examples = [
'<%= config.bin %> simulator:logs --follow',
'<%= config.bin %> simulator:logs --json',
];
static override flags = {
id: Flags.string({
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
}),
artifact: Flags.integer({
min: 1,
description: 'Artifact index to download from a stopped session (1-based).',
}),
output: Flags.string({
char: 'o',
description: 'Save a stopped session artifact to this path.',
}),
follow: Flags.boolean({
char: 'f',
description: 'Stream new logs. Start following before performing actions to collect them.',
exclusive: ['json'],
}),
scope: Flags.option({
description: 'Show user app logs or all device logs while the session runs.',
options: ['user-apps', 'all'] as const,
default: 'user-apps',
})(),
limit: Flags.integer({
description: 'Maximum number of buffered log lines to show while the session runs.',
default: 100,
min: 1,
}),
timestamp: Flags.boolean({
description: 'Show timestamps in live human-readable log output.',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};
async runAsync(): Promise<void> {
// Before parsing, so only the JSON result reaches stdout.
if (this.argv.includes('--json')) {
enableJsonOutput();
}
const { flags } = await this.parse(SimulatorLogs);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorLogs, { nonInteractive });
await loadSimulatorEnvAsync(projectDir);
const deviceRunSessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
if (!deviceRunSessionId) {
throw new Error(
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
);
}
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
if (hasSimulatorArtifacts(session)) {
if (flags.follow) {
throw new Error(
'The session has stopped. --follow is only available while it runs. Use --output <path> to download its logs artifact.'
);
}
await downloadSimulatorArtifactAsync(session, 'simulator-log', {
artifact: flags.artifact,
output: flags.output,
nonInteractive,
json: jsonFlag,
});
return;
}
if (session.platform === AppPlatform.Android) {
throw new Error(
'Live logs are only available for iOS simulator sessions. This session runs an Android emulator, whose preview server does not stream logs. Start an iOS session with `eas simulator:start --platform ios` to read logs.'
);
}
const preview = await resolveSimulatorPreviewAsync(session);
if (flags.artifact !== undefined || flags.output !== undefined) {
throw new Error(
'--artifact and --output are only available for stopped sessions. To save live logs, follow them into a file: `eas simulator:logs --follow > logs.txt`.'
);
}
const query = { scope: flags.scope, limit: String(flags.limit) };
if (flags.follow) {
const abortController = new AbortController();
const interruptHandler = (): void => {
if (abortController.signal.aborted) {
process.exit(130);
}
abortController.abort();
};
process.on('SIGINT', interruptHandler);
const stopHealthChecks = watchLogSourceHealth(preview, flags.scope, abortController.signal);
try {
await streamSimulatorPreviewAsync(
preview,
'/logs',
data => {
Log.log(flags.timestamp ? formatLogEnvelope(data) : formatLogLine(data));
},
{
query: { ...query, ...(flags.timestamp ? { envelope: '1' } : {}) },
signal: abortController.signal,
}
);
} finally {
stopHealthChecks();
process.removeListener('SIGINT', interruptHandler);
}
return;
}
const snapshot = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(preview, '/logs', {
query: { ...query, snapshot: '1' },
});
if (!jsonFlag && snapshot.streamError) {
Log.warn(stripTerminalControlCharacters(snapshot.streamError));
}
if (jsonFlag) {
printJsonOnlyOutput({ deviceRunSessionId: preview.deviceRunSessionId, ...snapshot });
return;
}
if (snapshot.lines.length === 0) {
// The warning above already explains why the buffer can be empty.
if (!snapshot.streamError) {
Log.log('No buffered logs. Use --follow to collect logs before performing actions.');
}
return;
}
for (const line of snapshot.lines) {
Log.log(formatLogLine(line.raw, flags.timestamp, line.at));
}
}
}
function formatLogEnvelope(data: string): string {
let line: Partial<SimulatorLogLine> | undefined;
try {
line = JSON.parse(data);
} catch {}
return typeof line?.raw === 'string' && isValidLogTime(line.at)
? formatLogLine(line.raw, true, line.at)
: formatLogLine(data);
}
/** Polls the log source while following and reports when it stops or recovers. Returns a stop function. */
function watchLogSourceHealth(
preview: SimulatorPreview,
scope: string,
signal: AbortSignal
): () => void {
let failing = false;
let checking = false;
const checkAsync = async (): Promise<void> => {
checking = true;
try {
const { status, streamError } = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(
preview,
'/logs',
{ query: { scope, limit: '1', snapshot: '1' } }
);
if (signal.aborted) {
return;
}
if (!failing && status !== 'streaming') {
failing = true;
const reason = streamError ? `: ${stripTerminalControlCharacters(streamError)}` : '';
// stderr, so the notice stays out of `--follow > logs.txt`.
Log.warnToStderr(
`Simulator logs are not being collected. The session's log source stopped${reason}. It restarts automatically, and logs from this gap are missing.`
);
} else if (failing && status === 'streaming' && !streamError) {
failing = false;
Log.warnToStderr('Simulator log collection resumed.');
}
} catch {
// The stream itself reports connection failures.
} finally {
checking = false;
}
};
const timer = setInterval(() => {
if (!checking) {
void checkAsync();
}
}, LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS);
return () => {
clearInterval(timer);
};
}
@@ -0,0 +1,168 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import { collectReferencedWorkflowFilesAsync } from '../../../commandUtils/workflow/referencedFiles';
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
import { WorkflowFile } from '../../../utils/workflowFile';
import WorkflowRun from '../run';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../commandUtils/workflow/referencedFiles');
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../../project/projectUtils');
jest.mock('../../../project/uploadAccountScopedFileAsync');
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../../utils/workflowFile');
jest.mock('../../../commandUtils/workflow/utils', () => ({
...jest.requireActual('../../../commandUtils/workflow/utils'),
fileExistsAsync: jest.fn(),
maybeReadStdinAsync: jest.fn(),
}));
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
const mockSpawn = jest.mocked(spawnAsync);
const mockCollectReferencedFiles = jest.mocked(collectReferencedWorkflowFilesAsync);
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
const mockGetOrCreateRevision = jest.mocked(
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
);
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
const mockFileExists = jest.mocked(fileExistsAsync);
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
const rootYamlConfig = 'jobs:\n ship:\n uses: ./.eas/workflows/build.yml\n';
const referencedFiles = [{ fileName: 'build.yml', yamlConfig: 'on:\n workflow_call: {}\n' }];
describe('WorkflowRun referenced workflow files', () => {
let mockConfig: Config;
let exitSpy: jest.SpyInstance;
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
mockReadStdin.mockResolvedValue(null);
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
mockValidate.mockResolvedValue(undefined as never);
mockReadWorkflow.mockResolvedValue({
filePath: '/test/project/.eas/workflows/test.yml',
yamlConfig: rootYamlConfig,
} as never);
mockCollectReferencedFiles.mockResolvedValue(referencedFiles);
mockFileExists.mockResolvedValue(false);
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
mockGetOrCreateRevision.mockResolvedValue({ id: 'rev-1', yamlConfig: rootYamlConfig } as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
function createCommand(flags: { ref?: string }): WorkflowRun {
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
getDynamicPrivateProjectConfigAsync: async () => ({
projectId: 'app-1',
exp: { slug: 'testapp' },
}),
loggedIn: { graphqlClient: {} },
vcsClient: { getRootPathAsync: async () => '/test/project' },
projectDir: '/test/project',
});
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({
flags: {
json: false,
wait: false,
'non-interactive': true,
input: undefined,
ref: flags.ref,
ssh: false,
'ssh-idle-timeout': undefined,
},
args: { file: '.eas/workflows/test.yml' },
});
return command;
}
it('sends the collected files to validation and to the run creation', async () => {
await expect(createCommand({}).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCollectReferencedFiles).toHaveBeenCalledWith({
projectDir: '/test/project',
rootFileName: 'test.yml',
rootYamlConfig,
});
expect(mockValidate).toHaveBeenCalledWith(
{},
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles }
);
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRevisionInput: {
fileName: 'test.yml',
yamlConfig: rootYamlConfig,
referencedFiles,
},
})
);
});
it('fails before validation and upload when the collector throws', async () => {
mockCollectReferencedFiles.mockRejectedValue(new Error('build.yml does not exist.'));
await expect(createCommand({}).runAsync()).rejects.toThrow('build.yml does not exist.');
expect(mockValidate).not.toHaveBeenCalled();
expect(mockUploadProject).not.toHaveBeenCalled();
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('does not collect local files when --ref is set', async () => {
await expect(createCommand({ ref: 'main' }).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCollectReferencedFiles).not.toHaveBeenCalled();
expect(mockValidate).toHaveBeenCalledWith(
{},
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles: undefined }
);
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
{},
expect.objectContaining({ workflowRevisionId: 'rev-1', gitRef: 'abc123' })
);
expect(mockCreateRun).not.toHaveBeenCalled();
});
});
@@ -9,7 +9,7 @@ import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel';
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select in-progress or queued runs to cancel';
static override strict = false;
@@ -22,21 +22,10 @@ export default class WorkflowRunCancel extends EasCommand {
};
async runAsync(): Promise<void> {
const { argv } = await this.parse(WorkflowRunCancel);
let nonInteractive = false;
const workflowRunIds: Set<string> = new Set();
// Custom parsing of argv
const tokens = [...argv] as string[];
while (tokens.length > 0) {
const token = tokens.shift();
if (token === '--non-interactive') {
nonInteractive = true;
continue;
} else if (token) {
workflowRunIds.add(token);
}
}
const { argv, flags } = await this.parse(WorkflowRunCancel);
// oclif removes declared flags such as --non-interactive from argv.
const nonInteractive = flags['non-interactive'];
const workflowRunIds = new Set(argv as string[]);
const {
projectId,
@@ -49,14 +38,13 @@ export default class WorkflowRunCancel extends EasCommand {
if (nonInteractive) {
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
}
// Run the workflow run list query and select runs to cancel
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
WorkflowRunStatus.InProgress,
50
const pickerStatuses = [WorkflowRunStatus.InProgress, WorkflowRunStatus.Waiting];
const runsByStatus = await Promise.all(
pickerStatuses.map(status =>
AppQuery.byIdWorkflowRunsFilteredByStatusAsync(graphqlClient, projectId, status, 50)
)
);
const runs = processWorkflowRuns(queryResult);
const runs = processWorkflowRuns(runsByStatus.flat());
if (runs.length === 0) {
Log.warn('No workflow runs to cancel');
return;
@@ -64,7 +52,7 @@ export default class WorkflowRunCancel extends EasCommand {
const answers = await promptAsync({
type: 'multiselect',
name: 'selectedRuns',
message: 'Select IN_PROGRESS workflow runs to cancel',
message: 'Select workflow runs to cancel',
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
answers.selectedRuns.forEach((id: string) => {
@@ -57,6 +57,7 @@ import {
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../commandUtils/workflow/inputs';
import { collectReferencedWorkflowFilesAsync } from '../../commandUtils/workflow/referencedFiles';
import {
fileExistsAsync,
maybeReadStdinAsync,
@@ -65,6 +66,7 @@ import {
} from '../../commandUtils/workflow/utils';
import {
WorkflowProjectSourceType,
WorkflowReferencedFileInput,
WorkflowRevision,
WorkflowRunStatus,
} from '../../graphql/generated';
@@ -179,6 +181,7 @@ export default class WorkflowRun extends EasCommand {
let workflowRunId: string;
let workflowRevisionId: string | undefined;
let gitRef: string | undefined;
let referencedFiles: WorkflowReferencedFileInput[] | undefined;
if (flags.ref) {
// Run from git ref
@@ -230,6 +233,11 @@ export default class WorkflowRun extends EasCommand {
throw err;
}
referencedFiles = await collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName: path.basename(args.file),
rootYamlConfig: yamlConfig,
});
}
// Validate workflow YAML
@@ -237,6 +245,7 @@ export default class WorkflowRun extends EasCommand {
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
appId: projectId,
yamlConfig,
referencedFiles,
});
} catch (error) {
if (error instanceof CombinedError) {
@@ -362,6 +371,7 @@ export default class WorkflowRun extends EasCommand {
workflowRevisionInput: {
fileName: path.basename(args.file),
yamlConfig,
referencedFiles,
},
workflowRunInput: {
inputs,
@@ -84,7 +84,7 @@ export default class WorkflowRunList extends EasCommand {
{ label: 'Workflow', value: run.workflowFileName },
{ label: 'Status', value: run.status },
{ label: 'Started At', value: run.startedAt },
{ label: 'Finished At', value: run.finishedAt },
{ label: 'Finished At', value: run.finishedAt ?? 'null' },
{ label: 'Trigger Type', value: run.triggerType },
{ label: 'Trigger', value: run.trigger ?? 'null' },
{ label: 'Git Commit Message', value: run.gitCommitMessage ?? 'null' },
+317 -9
View File
@@ -199,8 +199,7 @@ export type Account = {
* Whether the account turned on environment variable protection. When protection is on,
* reading, using, creating, changing, or deleting an environment variable whose environments are
* exclusively production needs the PUBLISH_PROTECTED permission, whatever its visibility, in
* every project of the account. Expo can also turn protection on for an account; this field
* does not show that.
* every project of the account.
*/
environmentVariableProtectionEnabled: Scalars['Boolean']['output'];
/** Environment variables for an account */
@@ -1100,6 +1099,7 @@ export type ActivityTimelineProjectActivity = {
export enum ActivityTimelineProjectActivityType {
Build = 'BUILD',
DeviceRunSession = 'DEVICE_RUN_SESSION',
Submission = 'SUBMISSION',
Update = 'UPDATE',
Worker = 'WORKER',
@@ -2487,11 +2487,13 @@ export type AppObserve = {
* @deprecated Use overview.stability instead.
*/
overviewStability: AppObserveOverviewStability;
/** One session's metrics and logs for the timeline (ENG-26160). Unknown session ids yield empty lists. */
/** One session's metrics, logs and spans for the timeline (ENG-26160). Unknown session ids yield empty lists. */
session: AppObserveSession;
/** @deprecated Use metrics.timeSeries instead. */
timeSeries: AppObserveTimeSeries;
totalEventCount: Scalars['Int']['output'];
/** Trace spans: today the HTTP request spans recorded with the SDK's networkTraces option (ENG-28448). */
traces: AppObserveTraces;
/**
* Approximate count of unique users (`eas_client_id`) with at least one
* supported-metric event in `[startTime, endTime)`. Uses a ClickHouse
@@ -3923,6 +3925,8 @@ export type AppObserveSession = {
logs: AppObserveLogConnection;
/** Metric samples in the session. */
metrics: AppObserveMetricConnection;
/** Trace spans in the session. Defaults to oldest first. */
spans: AppObserveSpanConnection;
};
@@ -3943,6 +3947,194 @@ export type AppObserveSession_MetricsArgs = {
orderBy?: InputMaybe<AppObserveMetricsListOrderBy>;
};
export type AppObserveSession_SpansArgs = {
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AppObserveSessionSpanFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
};
/** Filters for the spans of one session. The session already bounds the query. */
export type AppObserveSessionSpanFilter = {
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
names?: InputMaybe<Array<Scalars['String']['input']>>;
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
};
export type AppObserveSpan = {
__typename?: 'AppObserveSpan';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appIdentifier: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
/** Span attributes other than those extracted to dedicated fields. */
attributes: Array<AppObserveEventProperty>;
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
droppedAttributesCount: Scalars['Int']['output'];
droppedEventsCount: Scalars['Int']['output'];
/** Span links are not stored; this counts the links received. */
droppedLinksCount: Scalars['Int']['output'];
/** Keeps sub-millisecond precision. */
durationMs: Scalars['Float']['output'];
easClientId: Scalars['String']['output'];
endTimestamp: Scalars['DateTime']['output'];
environment?: Maybe<Scalars['String']['output']>;
events: Array<AppObserveSpanEvent>;
expoSdkVersion?: Maybe<Scalars['String']['output']>;
/** Null when the span is not an HTTP request span. */
http?: Maybe<AppObserveSpanHttp>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
kind: AppObserveSpanKind;
/** Per the producer's semantic conventions: the HTTP method for request spans. */
name: Scalars['String']['output'];
/** Null for a root span. */
parentSpanId?: Maybe<Scalars['String']['output']>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
spanId: Scalars['String']['output'];
startTimestamp: Scalars['DateTime']['output'];
statusCode: AppObserveSpanStatusCode;
statusMessage?: Maybe<Scalars['String']['output']>;
traceId: Scalars['String']['output'];
};
export type AppObserveSpanAttributeFilter = {
key: Scalars['String']['input'];
value: Scalars['String']['input'];
};
export type AppObserveSpanConnection = {
__typename?: 'AppObserveSpanConnection';
edges: Array<AppObserveSpanEdge>;
pageInfo: PageInfo;
};
export type AppObserveSpanEdge = {
__typename?: 'AppObserveSpanEdge';
cursor: Scalars['String']['output'];
node: AppObserveSpan;
};
export type AppObserveSpanEvent = {
__typename?: 'AppObserveSpanEvent';
/** The event's attributes as a JSON object. */
attributes: Scalars['JSON']['output'];
name: Scalars['String']['output'];
timestamp: Scalars['DateTime']['output'];
};
/** HTTP request fields of a span, from the OpenTelemetry HTTP client semantic conventions. */
export type AppObserveSpanHttp = {
__typename?: 'AppObserveSpanHttp';
/** Set when the request failed before a response: an NSURLError code on iOS, an exception class on Android. */
errorType?: Maybe<Scalars['String']['output']>;
method?: Maybe<Scalars['String']['output']>;
protocolVersion?: Maybe<Scalars['String']['output']>;
/** On-the-wire bytes including headers. */
requestSize?: Maybe<Scalars['Int']['output']>;
responseSize?: Maybe<Scalars['Int']['output']>;
/** Null when the request got no response. */
statusCode?: Maybe<Scalars['Int']['output']>;
/** Credentials and signing query parameters are redacted at ingestion. */
urlFull?: Maybe<Scalars['String']['output']>;
urlHost?: Maybe<Scalars['String']['output']>;
};
export enum AppObserveSpanKind {
Client = 'CLIENT',
Consumer = 'CONSUMER',
Internal = 'INTERNAL',
Producer = 'PRODUCER',
Server = 'SERVER',
Unspecified = 'UNSPECIFIED'
}
export type AppObserveSpanListFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** At most 5. Matches spans whose attribute has exactly this value. */
attributeFilters?: InputMaybe<Array<AppObserveSpanAttributeFilter>>;
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
/** HTTP request spans to any of these hosts. */
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
/** HTTP request spans with any of these response status codes. */
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
/** Span names, such as GET or POST. The cheapest filter: span name is in the table's sort key. */
names?: InputMaybe<Array<Scalars['String']['input']>>;
/** Filter to these platforms. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
/** Only spans with no parent. */
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
/** 32-character hex trace id. */
traceId?: InputMaybe<Scalars['String']['input']>;
};
export type AppObserveSpanListOrderBy = {
direction: AppObserveOrderDirection;
field: AppObserveSpanListOrderByField;
};
export enum AppObserveSpanListOrderByField {
StartTimestamp = 'START_TIMESTAMP'
}
export type AppObserveSpanName = {
__typename?: 'AppObserveSpanName';
count: Scalars['Int']['output'];
kind: AppObserveSpanKind;
name: Scalars['String']['output'];
};
export type AppObserveSpanNames = {
__typename?: 'AppObserveSpanNames';
/** True when more names exist than were returned (top-N truncation). */
isTruncated: Scalars['Boolean']['output'];
names: Array<AppObserveSpanName>;
};
export type AppObserveSpanNamesInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
/** Maximum names to return, 1 to 500. Defaults to 100. */
limit?: InputMaybe<Scalars['Int']['input']>;
/** Filter to these platforms. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
export enum AppObserveSpanStatusCode {
Error = 'ERROR',
Ok = 'OK',
Unset = 'UNSET'
}
/** Stability-tile filters. No release filters: the tiles always span all versions. */
export type AppObserveStabilityInput = {
/** Series bucket size. Defaults to one day. */
@@ -4006,6 +4198,70 @@ export type AppObserveTimeSeriesStatistics = {
p99?: Maybe<Scalars['Float']['output']>;
};
export type AppObserveTrace = {
__typename?: 'AppObserveTrace';
id: Scalars['ID']['output'];
/** True when the trace has more spans than were returned. */
isTruncated: Scalars['Boolean']['output'];
/** Oldest first. The root span, when present, is the one with no parent. */
spans: Array<AppObserveSpan>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces = {
__typename?: 'AppObserveTraces';
/** Distinct span names and kinds with counts over the time range, most frequent first. */
names: AppObserveSpanNames;
/** A single span by id. Null when missing or aged out of retention. */
span?: Maybe<AppObserveSpan>;
/** Individual spans. Defaults to newest first. */
spans: AppObserveSpanConnection;
/** All spans of one trace by its 32-character hex trace id. Null when unknown or aged out of retention. */
trace?: Maybe<AppObserveTrace>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_NamesArgs = {
input: AppObserveSpanNamesInput;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_SpanArgs = {
id: Scalars['ID']['input'];
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_SpansArgs = {
after?: InputMaybe<Scalars['String']['input']>;
filter: AppObserveSpanListFilter;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_TraceArgs = {
id: Scalars['ID']['input'];
};
export type AppObserveUpdate = {
__typename?: 'AppObserveUpdate';
appUpdateId: Scalars['String']['output'];
@@ -7104,8 +7360,10 @@ export type DeploymentsMutation_DeleteWorkerDeploymentByIdentifierArgs = {
deploymentIdentifier: Scalars['ID']['input'];
};
export type DeviceRunSession = {
export type DeviceRunSession = ActivityTimelineProjectActivity & {
__typename?: 'DeviceRunSession';
activityTimestamp: Scalars['DateTime']['output'];
actor?: Maybe<Actor>;
app: App;
/**
* Direct application archive URL installed and launched for this session. Null when
@@ -13923,6 +14181,13 @@ export type WorkflowJob = {
__typename?: 'WorkflowJob';
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
approvals: Array<WorkflowJobApproval>;
/**
* The workflow run started by a WORKFLOW_CALL job. Null for other job types, while the
* called run is not created yet, and for a failed call that never started a run. A call
* job copied by "re-run from failed jobs" resolves to the called run of the attempt it
* was copied from.
*/
childWorkflowRun?: Maybe<WorkflowRun>;
createdAt: Scalars['DateTime']['output'];
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
/**
@@ -14058,6 +14323,7 @@ export enum WorkflowJobType {
Custom = 'CUSTOM',
Deploy = 'DEPLOY',
Doc = 'DOC',
Email = 'EMAIL',
Fingerprint = 'FINGERPRINT',
GetBuild = 'GET_BUILD',
GithubComment = 'GITHUB_COMMENT',
@@ -14104,6 +14370,11 @@ export type WorkflowQuery_ByIdArgs = {
workflowId: Scalars['ID']['input'];
};
export type WorkflowReferencedFileInput = {
fileName: Scalars['String']['input'];
yamlConfig: Scalars['String']['input'];
};
export type WorkflowRevision = {
__typename?: 'WorkflowRevision';
blobSha: Scalars['String']['output'];
@@ -14123,6 +14394,12 @@ export type WorkflowRevisionEdge = {
export type WorkflowRevisionInput = {
fileName: Scalars['String']['input'];
/**
* Other workflow files of the project that `uses` jobs reference, by bare file name.
* Required when the workflow has `uses` jobs. Every uploaded file is stored as a
* revision and pinned on the run, whether or not a job calls it.
*/
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
yamlConfig: Scalars['String']['input'];
};
@@ -14149,6 +14426,7 @@ export type WorkflowRevisionMutation_GetWorkflowRevisionsFromGitRefArgs = {
export type WorkflowRevisionMutation_ValidateWorkflowYamlConfigArgs = {
appId: Scalars['ID']['input'];
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
yamlConfig: Scalars['String']['input'];
};
@@ -14173,6 +14451,12 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
activityTimestamp: Scalars['DateTime']['output'];
actor?: Maybe<Actor>;
app: App;
/**
* The run currently holding the concurrency group this run is waiting for.
* Null for runs that are not waiting, and briefly null while the group's
* next run is being promoted.
*/
blockingWorkflowRun?: Maybe<WorkflowRun>;
/**
* Why the server canceled this run. Null for manually canceled runs and for
* runs canceled before the reason was recorded.
@@ -14192,6 +14476,11 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
jobs: Array<WorkflowJob>;
/** Snapshot of the workflow's name at run creation. */
name: Scalars['String']['output'];
/**
* The WORKFLOW_CALL job of another run that started this run. Null for runs started by
* a trigger, a manual dispatch or a retry. Called runs are omitted from run lists.
*/
parentWorkflowJob?: Maybe<WorkflowJob>;
pullRequestNumber?: Maybe<Scalars['Int']['output']>;
requestedGitRef?: Maybe<Scalars['String']['output']>;
retriedWorkflowRun?: Maybe<WorkflowRun>;
@@ -14207,7 +14496,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
workflowRevision?: Maybe<WorkflowRevision>;
};
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonWorkflowCall;
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonConcurrencyGroupQueueFull | WorkflowRunCancelReasonConcurrencyGroupReplaced | WorkflowRunCancelReasonWorkflowCall;
export type WorkflowRunCancelReasonConcurrencyGroup = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroup';
@@ -14218,6 +14507,24 @@ export type WorkflowRunCancelReasonConcurrencyGroup = {
cancelingWorkflowRun?: Maybe<WorkflowRun>;
};
export type WorkflowRunCancelReasonConcurrencyGroupQueueFull = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupQueueFull';
/**
* The waiting-run capacity the concurrency group's queue was at when this
* run arrived.
*/
maximumWaitingRuns: Scalars['Int']['output'];
};
export type WorkflowRunCancelReasonConcurrencyGroupReplaced = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupReplaced';
/**
* The newer run that took this run's place in the concurrency group's waiting
* slot. Null if that run no longer exists.
*/
replacingWorkflowRun?: Maybe<WorkflowRun>;
};
export type WorkflowRunCancelReasonWorkflowCall = {
__typename?: 'WorkflowRunCancelReasonWorkflowCall';
/**
@@ -15624,6 +15931,7 @@ export type GetOrCreateWorkflowRevisionFromGitRefMutation = { __typename?: 'Root
export type ValidateWorkflowYamlConfigMutationVariables = Exact<{
appId: Scalars['ID']['input'];
yamlConfig: Scalars['String']['input'];
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput> | WorkflowReferencedFileInput>;
}>;
@@ -15752,7 +16060,7 @@ export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
}>;
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -16598,7 +16906,7 @@ export type WorkflowRunByIdWithJobsQueryVariables = Exact<{
}>;
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, blockingWorkflowRun?: { __typename?: 'WorkflowRun', id: string, name: string, status: WorkflowRunStatus } | null, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
| { __typename: 'PartnerActor', id: string, displayName: string }
| { __typename: 'Robot', id: string, displayName: string }
| { __typename: 'SSOUser', id: string, displayName: string }
@@ -16618,7 +16926,7 @@ export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
}>;
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -16810,7 +17118,7 @@ export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key:
| { __typename: 'User', id: string, displayName: string }
| null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -8,6 +8,7 @@ import {
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
ValidateWorkflowYamlConfigMutation,
ValidateWorkflowYamlConfigMutationVariables,
WorkflowReferencedFileInput,
WorkflowRevision,
} from '../generated';
@@ -72,24 +73,35 @@ export namespace WorkflowRevisionMutation {
{
appId,
yamlConfig,
referencedFiles,
}: {
appId: string;
yamlConfig: string;
referencedFiles?: WorkflowReferencedFileInput[];
}
): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<ValidateWorkflowYamlConfigMutation, ValidateWorkflowYamlConfigMutationVariables>(
gql`
mutation ValidateWorkflowYamlConfig($appId: ID!, $yamlConfig: String!) {
mutation ValidateWorkflowYamlConfig(
$appId: ID!
$yamlConfig: String!
$referencedFiles: [WorkflowReferencedFileInput!]
) {
workflowRevision {
validateWorkflowYamlConfig(appId: $appId, yamlConfig: $yamlConfig)
validateWorkflowYamlConfig(
appId: $appId
yamlConfig: $yamlConfig
referencedFiles: $referencedFiles
)
}
}
`,
{
appId,
yamlConfig,
referencedFiles,
}
)
.toPromise()
@@ -118,11 +118,17 @@ export const WorkflowRunQuery = {
workflowRuns {
byId(workflowRunId: $workflowRunId) {
id
blockingWorkflowRun {
id
name
status
}
workflow {
id
app {
id
name
slug
ownerAccount {
id
name
@@ -22,6 +22,7 @@ export const WorkflowRunFragmentNode = gql`
triggeringSchedule
createdAt
updatedAt
finalizedAt
errors {
title
message
+12 -6
View File
@@ -6,13 +6,19 @@ export default class EasHelp extends Help {
const [command, ...args] = argv;
if (command === 'simulator:control' || command === 'sim:control') {
const { default: SimulatorControl } = await import('./commands/simulator/control');
const parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
let parsed;
try {
parsed = await Parser.parse(args, {
args: SimulatorControl.args,
flags: SimulatorControl.flags,
strict: false,
});
} catch {
await super.showHelp(argv);
return;
}
if (parsed.args.action && parsed.flags.help) {
await SimulatorControl.run(args, this.config);
await this.config.runCommand('simulator:control', args);
return;
}
}
+12
View File
@@ -39,6 +39,18 @@ export default class Log {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static errorToStderr(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.error(...Log.withTextColor(args, chalk.stderr.red));
}
public static warnToStderr(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.error(...Log.withTextColor(args, chalk.stderr.yellow));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
@@ -466,6 +466,7 @@ function colorWorkflowStatus(status: string): string {
case WorkflowRunStatus.ActionRequired:
return chalk.yellow('action required');
case WorkflowRunStatus.New:
case WorkflowRunStatus.Waiting:
case WorkflowRunStatus.InProgress:
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
default:
@@ -0,0 +1,235 @@
import fs from 'fs-extra';
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import fetch, { Response } from '../../fetch';
import { DeviceRunSessionStatus } from '../../graphql/generated';
import Log from '../../log';
import { promptAsync, selectAsync } from '../../prompts';
import { printJsonOnlyOutput } from '../../utils/json';
import {
type SimulatorSession,
downloadSimulatorArtifactAsync,
hasSimulatorArtifacts,
} from '../artifacts';
import { downloadSimulatorFileAsync } from '../download';
jest.mock('../../fetch');
jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => ({ start: () => ({ succeed: jest.fn(), fail: jest.fn() }) }),
}));
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../download');
function artifact(
id: string,
type = 'simulator-log',
createdAt = '2026-10-07T12:00:00Z'
): SimulatorSession['artifacts'][number] {
return {
id,
name: `Log ${id}`,
filename: `${id}.ndjson`,
downloadUrl: `https://storage.test/${id}?token=secret`,
createdAt,
updatedAt: createdAt,
fileSizeBytes: 100,
metadata: { __eas_type: type, udid: 'SIM-A', part: 2 },
};
}
const first = artifact('a');
function session(artifacts = [first]): SimulatorSession {
return {
id: 'session-id',
status: DeviceRunSessionStatus.Stopped,
artifacts,
} as SimulatorSession;
}
const options = { output: 'logs.ndjson', nonInteractive: true, json: false };
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(downloadSimulatorFileAsync).mockResolvedValue('/logs.ndjson');
});
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
'uses artifacts for %s',
status => {
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(true);
}
);
it.each([
DeviceRunSessionStatus.InProgress,
DeviceRunSessionStatus.Queued,
DeviceRunSessionStatus.Starting,
])('does not use artifacts for %s', status => {
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(false);
});
it('reports missing artifacts without contacting storage', async () => {
await expect(
downloadSimulatorArtifactAsync(
session([artifact('a', 'session-events')]),
'simulator-log',
options
)
).rejects.toThrow('No matching artifacts');
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('automatically selects one artifact and prints its saved path', async () => {
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('logs.ndjson', expect.any(Function));
expect(Log.log).toHaveBeenCalledWith('Saved Log a to /logs.ndjson.');
expect(selectAsync).not.toHaveBeenCalled();
});
it('sorts available indexes, sanitizes labels, and requires explicit selection noninteractively', async () => {
const older = {
...artifact('b', 'simulator-log', '2026-10-07T11:00:00Z'),
name: '\u001b[31mEarlier\u001b[0m\u0007',
};
await expect(
downloadSimulatorArtifactAsync(session([first, older]), 'simulator-log', options)
).rejects.toThrow(
'1. Earlier — b.ndjson (100 B) (SIM-A) (part 2)\n2. Log a — a.ndjson (100 B) (SIM-A) (part 2)'
);
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it.each([0, -1, 3, 1.5])('rejects invalid index %s and lists indexes', async index => {
await expect(
downloadSimulatorArtifactAsync(session([first, artifact('b')]), 'simulator-log', {
...options,
artifact: index,
})
).rejects.toThrow('out of range. Choose an artifact with --artifact');
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('uses the explicit 1-based index and returns metadata without storage credentials', async () => {
const second = artifact('b');
await downloadSimulatorArtifactAsync(session([second, first]), 'simulator-log', {
...options,
artifact: 2,
json: true,
});
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
deviceRunSessionId: 'session-id',
artifact: {
id: second.id,
name: second.name,
filename: second.filename,
metadata: second.metadata,
},
filePath: '/logs.ndjson',
});
expect(Log.log).not.toHaveBeenCalled();
expect(JSON.stringify(jest.mocked(printJsonOnlyOutput).mock.calls)).not.toContain('secret');
});
it('prompts for a selection and destination with a basename default', async () => {
jest.mocked(selectAsync).mockResolvedValue(2);
jest.mocked(promptAsync).mockResolvedValue({ filePath: 'chosen.ndjson' });
await downloadSimulatorArtifactAsync(
session([first, { ...artifact('b'), filename: '../saved.ndjson' }]),
'simulator-log',
{ nonInteractive: false, json: false }
);
expect(selectAsync).toHaveBeenCalledWith('Select an artifact to download:', [
{ title: '1. Log a — a.ndjson (100 B) (SIM-A) (part 2)', value: 1 },
{ title: '2. Log b — ../saved.ndjson (100 B) (SIM-A) (part 2)', value: 2 },
]);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ initial: 'saved.ndjson' }));
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('chosen.ndjson', expect.any(Function));
});
it('requires output without prompting in noninteractive mode', async () => {
await expect(
downloadSimulatorArtifactAsync(session(), 'simulator-log', { nonInteractive: true, json: true })
).rejects.toThrow('Pass --output');
expect(promptAsync).not.toHaveBeenCalled();
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
});
it('downloads from storage without a preview bearer or redirect restrictions', async () => {
const response = new Response(Readable.from(['logs']));
jest.mocked(fetch).mockResolvedValue(response);
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
await expect(getResponse(new AbortController().signal)).resolves.toBe(response);
return '/logs.ndjson';
});
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
expect(fetch).toHaveBeenCalledWith(first.downloadUrl, {
signal: expect.any(AbortSignal),
timeout: 30_000,
});
});
it('redacts signed URLs from storage failures', async () => {
jest.mocked(fetch).mockRejectedValue(new Error(first.downloadUrl));
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
await getResponse(new AbortController().signal);
return '/logs.ndjson';
});
const downloading = downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
await expect(downloading).rejects.toThrow('Could not download the session artifact');
await expect(downloading).rejects.not.toThrow('secret');
});
it('closes an unfinished HTTP error response without exposing storage credentials', async () => {
const actualFetch = jest.requireActual<typeof import('../../fetch')>('../../fetch').default;
const actualDownload = jest.requireActual<typeof import('../download')>('../download');
jest.mocked(fetch).mockImplementation(actualFetch);
jest
.mocked(downloadSimulatorFileAsync)
.mockImplementation(actualDownload.downloadSimulatorFileAsync);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-error-'));
let markClosed = (): void => {};
const closed = new Promise<void>(resolve => {
markClosed = resolve;
});
const server = createServer((_request, response) => {
response.once('close', markClosed);
response.writeHead(403);
response.write('secret storage token');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const failedArtifact = {
...first,
downloadUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/?token=secret`,
};
let expired = false;
const deadline = setTimeout(() => {
expired = true;
server.closeAllConnections();
}, 2_000);
try {
const downloading = downloadSimulatorArtifactAsync(session([failedArtifact]), 'simulator-log', {
...options,
output: path.join(directory, 'logs.ndjson'),
});
await expect(downloading).rejects.toThrow('Could not download the session artifact');
await expect(downloading).rejects.not.toThrow('secret');
await closed;
expect(expired).toBe(false);
expect(await fs.readdir(directory)).toEqual([]);
} finally {
clearTimeout(deadline);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
await fs.remove(directory);
}
});
@@ -0,0 +1,133 @@
import fs from 'fs-extra';
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import fetch, { Response } from '../../fetch';
import { downloadSimulatorFileAsync } from '../download';
let directory: string;
let output: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-test-'));
output = path.join(directory, 'capture.har');
});
afterEach(async () => {
await fs.remove(directory);
});
it('writes private files and removes its interrupt listener after success', async () => {
const listeners = process.listeners('SIGINT');
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['private'])))
).resolves.toBe(output);
expect(await fs.readFile(output, 'utf8')).toBe('private');
expect((await fs.stat(output)).mode & 0o777).toBe(0o600);
expect(process.listeners('SIGINT')).toEqual(listeners);
});
it('preserves an existing output file', async () => {
await fs.writeFile(output, 'existing');
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
).rejects.toThrow('already exists');
expect(await fs.readFile(output, 'utf8')).toBe('existing');
});
it('refuses a symlink without changing its target', async () => {
const target = path.join(directory, 'target');
await fs.writeFile(target, 'existing');
await fs.symlink(target, output);
await expect(
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
).rejects.toThrow('already exists');
expect(await fs.readFile(target, 'utf8')).toBe('existing');
expect((await fs.lstat(output)).isSymbolicLink()).toBe(true);
});
it('preserves a file created by a competing process during the request', async () => {
await expect(
downloadSimulatorFileAsync(output, async () => {
await fs.writeFile(output, 'competitor');
return new Response(Readable.from(['replacement']));
})
).rejects.toThrow('already exists');
expect(await fs.readFile(output, 'utf8')).toBe('competitor');
});
it('removes only its partial file after a body failure', async () => {
const body = new PassThrough();
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
body.write('partial');
while (!(await fs.pathExists(output))) {
await new Promise(resolve => setImmediate(resolve));
}
body.destroy(new Error('secret storage URL'));
await expect(downloading).rejects.toThrow('Could not save the download');
expect(await fs.pathExists(output)).toBe(false);
});
it('removes the file on an immediate response failure', async () => {
const body = Readable.from(
(async function* () {
yield 'partial';
throw new Error('secret storage URL');
})()
);
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
await expect(downloading).rejects.toThrow('Could not save the download');
expect(await fs.readdir(directory)).toEqual([]);
});
it('preserves existing files on request failure', async () => {
await expect(
downloadSimulatorFileAsync(output, async () => {
await fs.writeFile(output, 'existing');
throw new Error('request failed');
})
).rejects.toThrow('request failed');
expect(await fs.readFile(output, 'utf8')).toBe('existing');
});
it('follows storage redirects and cancels a real HTTP download with full cleanup', async () => {
let markClosed: () => void = () => {};
const closed = new Promise<void>(resolve => {
markClosed = resolve;
});
const server = createServer((request, response) => {
if (request.url === '/redirect') {
response.writeHead(302, { location: '/body' });
response.end();
} else {
response.once('close', markClosed);
response.writeHead(200);
response.write('partial');
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/redirect`;
const listeners = process.listeners('SIGINT');
try {
const downloading = downloadSimulatorFileAsync(
output,
async signal => await fetch(url, { signal })
);
while (!(await fs.pathExists(output))) {
await new Promise(resolve => setImmediate(resolve));
}
process.emit('SIGINT');
await expect(downloading).rejects.toThrow('canceled');
await closed;
expect(await fs.pathExists(output)).toBe(false);
expect(process.listeners('SIGINT')).toEqual(listeners);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more