Compare commits

..
Author SHA1 Message Date
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
Expo CI 56d01c4129 v26.0.0 2026-10-26 16:14:04 +00:00
Stanisław Chmiela bcb5d96ea8 [cocoapods-proxy] Add Sonatype snapshot CocoaPods proxy support (#3736)
## Summary

Adds `central.sonatype.com` support to the packaged `expo-cocoapods-proxy` gem used by iOS workers.

## Change

- Adds `central.sonatype.com` to the `Pod::Downloader::Http` proxy allowlist.
- Adds spec coverage for Sonatype repository artifact URLs.

## Code Requiring This

This PR is for CocoaPods `:http` pod source downloads. It does not proxy React Native's `Net::HTTP` metadata lookup.

React Native returns Sonatype artifact URLs to CocoaPods as `:http` pod sources here:

- RNCore returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rncore.rb#L170-L173
- ReactNativeDependencies returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rndependencies.rb#L205-L208
- Hermes returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/sdks/hermes-engine/hermes-utils.rb#L190-L193

CocoaPods/cocoapods-downloader then uses `Pod::Downloader::Http` for those `:http` params:

- CocoaPods passes the pod source params to `Downloader.for_target(...).download`: https://github.com/CocoaPods/CocoaPods/blob/1.12.1/lib/cocoapods/downloader.rb#L107-L110
- `cocoapods-downloader` maps `:http` to `Http`: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L22-L30
- `Downloader.for_target` instantiates that downloader with the `:http` URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L51-L64
- `Pod::Downloader::Http#download_file` uses curl for that URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader/http.rb#L12-L22

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01:00
109 changed files with 3952 additions and 1091 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Regenerate graphql code
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
@@ -1,5 +0,0 @@
---
'@expo/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
-6
View File
@@ -1,6 +0,0 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
-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
+63
View File
@@ -2,6 +2,69 @@
This is the log of notable changes to EAS CLI and related packages.
## [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
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
+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": "25.3.0",
"version": "27.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+30
View File
@@ -1,5 +1,35 @@
# @expo/build-tools
## 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
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.3.0",
"version": "27.0.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": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/downloader": "27.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.3.0",
"@expo/logger": "27.0.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": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/steps": "27.0.0",
"@expo/template-file": "27.0.0",
"@expo/turtle-spawn": "27.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -67,7 +67,6 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
@@ -145,7 +144,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
@@ -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({
@@ -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',
]);
});
@@ -0,0 +1,112 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -1,239 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { getEasFunctions } from '../../easFunctions';
import { readIpaInfoAsync } from '../readIpaInfo';
import { createUploadToAscBuildFunction } from '../uploadToAsc';
jest.unmock('node-fetch');
jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../readIpaInfo', () => ({
...jest.requireActual('../readIpaInfo'),
readIpaInfoAsync: jest.fn(),
}));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
const logger = createMockLogger();
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });
async function waitAsync(diagnostics: Record<string, string> = {}): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
)!;
await fn
.createBuildStepFromFunctionCall(globalContext(), {
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics },
})
.executeAsync();
}
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
await fs.outputFile('/project/app.ipa', 'ipa');
jest.mocked(readIpaInfoAsync).mockResolvedValue({
bundleIdentifier: 'com.test.ipa',
bundleVersion: '1',
bundleShortVersion: '1.0',
dtPlatformName: 'iphoneos',
});
mockUpload();
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), {
callInputs: {
ipa_path: 'app.ipa',
asc_api_key_path: 'key.json',
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
},
});
await step.executeAsync();
}
function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } },
});
}
function mockApp(): void {
api()
.get('/v1/apps/app')
.query(true)
.reply(200, {
data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } },
});
}
function mockUpload(): void {
mockApp();
api()
.post('/v1/buildUploads')
.reply(201, { data: { type: 'buildUploads', id: 'upload' } });
api()
.post('/v1/buildUploadFiles')
.reply(201, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: {
uploadOperations: [
{
length: 3,
offset: 0,
partNumber: 1,
method: 'PUT',
requestHeaders: [],
url: 'https://upload.example.com/ipa',
},
],
},
},
});
nock('https://upload.example.com').put('/ipa', 'ipa').reply(200);
api()
.get('/v1/buildUploadFiles/file')
.query(true)
.twice()
.reply(200, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
},
});
}
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
jest.mocked(setTimeout).mockResolvedValue(undefined);
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
jest.restoreAllMocks();
}
});
it('polls an existing upload through awaiting and processing states until complete', async () => {
mockProcessing('AWAITING_UPLOAD');
mockProcessing('PROCESSING');
mockProcessing('COMPLETE');
await expect(waitAsync()).resolves.toBeUndefined();
});
it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
mockProcessing(
'FAILED',
['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' }))
);
mockApp();
api().get('/v1/apps').query(true).reply(200, { data: [] });
await expect(
waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' })
).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
message: expect.stringContaining(
'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app'
),
});
});
it.each<[string, string[], string]>([
['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'],
['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'],
])('reports a %s processing error', async (_label, codes, errorCode) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toMatchObject({ errorCode });
});
it.each(
[
[],
['OTHER'],
['90725', 'OTHER'],
['90062', 'OTHER'],
['90054', 'OTHER'],
['90683', 'OTHER'],
].map(codes => ({ codes }))
)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.');
});
it('reports missing privacy keys once each', async () => {
mockProcessing('FAILED', [
{
code: '90683',
description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.',
},
{ code: '90683', description: 'Missing NSCameraUsageDescription.' },
]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.stringContaining(
'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n'
),
});
});
it('reports missing privacy purpose strings when Apple does not name a key', async () => {
mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.not.stringContaining('Missing keys reported by App Store Connect:'),
});
});
it.each([undefined, false, true])(
'keeps upload wait_for_processing=%s compatible',
async waitForProcessing => {
if (waitForProcessing) {
mockProcessing('PROCESSING');
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
}
if (waitForProcessing) {
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
});
} else {
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
}
}
);
it.each(['upload', 'wait'])(
'preserves the successful timeout return for the %s step',
async step => {
mockProcessing('PROCESSING');
const start = Date.now();
jest.spyOn(Date, 'now').mockReturnValue(start);
jest.mocked(setTimeout).mockImplementationOnce(async () => {
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
});
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined();
}
);
@@ -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 });
@@ -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();
@@ -93,7 +93,7 @@ export async function updateTestFlightMetadataAsync({
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.'
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
@@ -14,7 +15,6 @@ import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
import { waitForAscBuildProcessingAsync } from './waitForAscBuildProcessing';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const { errors = [], warnings = [] } = assetDeliveryState;
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (assetDeliveryState.state === 'FAILED') {
@@ -208,19 +208,165 @@ export function createUploadToAscBuildFunction(): BuildFunction {
return;
}
await waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger: stepsCtx.logger,
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
appleAppIdentifier: undefined,
ascAppBundleIdentifier,
bundleShortVersion,
});
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
stepsCtx.logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
stepsCtx.logger.info(`Infos:\n${itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
},
});
}
function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -1,245 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'wait_for_asc_build_processing',
name: 'Wait for App Store Connect processing',
__metricsId: 'eas/wait_for_asc_build_processing',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'apple_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_short_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
bundle_identifier: z.string().optional(),
apple_app_identifier: z.string().optional(),
bundle_short_version: z.string().optional(),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
bundle_identifier: inputs.bundle_identifier.value,
apple_app_identifier: inputs.apple_app_identifier.value,
bundle_short_version: inputs.bundle_short_version.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await waitForAscBuildProcessingAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
logger: ctx.logger,
ipaBundleIdentifier: parsedInputs.bundle_identifier,
appleAppIdentifier: parsedInputs.apple_app_identifier,
ascAppBundleIdentifier: undefined,
bundleShortVersion: parsedInputs.bundle_short_version,
});
},
});
}
export async function waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger,
ipaBundleIdentifier,
appleAppIdentifier,
ascAppBundleIdentifier,
bundleShortVersion,
}: {
client: AscApiClient;
buildUploadId: string;
logger: bunyan;
ipaBundleIdentifier: string | undefined;
appleAppIdentifier: string | undefined;
ascAppBundleIdentifier: string | undefined;
bundleShortVersion: string | undefined;
}): Promise<void> {
logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
}
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
@@ -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');
@@ -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', {
@@ -302,8 +302,4 @@ export namespace AscApiUtils {
.join('\n') || ' (none)'
);
}
export function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
}
@@ -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)
@@ -1,5 +1,13 @@
# create-eas-build-function
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.3.0",
"version": "27.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.3.0"
"@expo/steps": "27.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+8
View File
@@ -1,5 +1,13 @@
# @expo/downloader
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.3.0",
"version": "27.0.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.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.3.0",
"version": "27.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+14
View File
@@ -1,5 +1,19 @@
# @expo/eas-build-job
## 27.0.0
No changes in this release.
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.3.0",
"version": "27.0.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": "25.3.0",
"@expo/logger": "27.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "27.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+31
View File
@@ -1,5 +1,36 @@
# eas-cli
## 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
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
### Minor Changes
+268 -198
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.3.0",
"version": "27.0.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": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/eas-build-job": "27.0.0",
"@expo/eas-json": "27.0.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.3.0",
"@expo/logger": "27.0.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": "25.3.0",
"@expo/steps": "27.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -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 {}
@@ -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);
}
@@ -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);
};
}
+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));
}
@@ -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();
})
);
}
});
@@ -0,0 +1,473 @@
import { createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import { PassThrough, Readable } from 'node:stream';
import fetch, { Headers, RequestError, Response } from '../../fetch';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
import {
fetchSimulatorPreviewAsync,
fetchSimulatorPreviewJsonAsync,
resolveSimulatorPreviewAsync,
streamSimulatorPreviewAsync,
} from '../preview';
jest.mock('../../fetch', () => ({
__esModule: true,
...jest.requireActual('../../fetch'),
default: jest.fn(),
}));
const preview = {
deviceRunSessionId: 'session-id',
baseUrl: new URL('https://preview.test'),
token: 'secret-token',
};
function session(
remoteConfig: unknown,
status = DeviceRunSessionStatus.InProgress
): DeviceRunSessionByIdQuery['deviceRunSessions']['byId'] {
return {
id: 'session-id',
status,
remoteConfig,
} as DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
}
function rejectWithStatus(status: number): void {
jest
.mocked(fetch)
.mockRejectedValue(
new RequestError('not found', new Response(Readable.from(['']), { status }))
);
}
beforeEach(() => {
jest.clearAllMocks();
});
describe(resolveSimulatorPreviewAsync, () => {
it.each([
['ArgentRunSessionRemoteConfig', 'webPreviewToken'],
['AgentDeviceRunSessionRemoteConfig', 'webPreviewToken'],
['AppiumRunSessionRemoteConfig', 'webPreviewToken'],
['ServeSimRunSessionRemoteConfig', 'previewToken'],
['WebPreviewOnlyRunSessionRemoteConfig', 'previewToken'],
])(
'resolves preview authentication for %s without using controller credentials',
async (__typename, tokenKey) => {
const currentSession = session({
__typename,
previewApiUrl: 'https://preview.test/prefix/?device=SIM-A&token=old-token#fragment',
[tokenKey]: 'preview-token',
toolsAuthToken: 'wrong-controller-token',
agentDeviceRemoteSessionToken: 'wrong-controller-token',
});
const resolved = await resolveSimulatorPreviewAsync(currentSession);
expect(resolved.token).toBe('preview-token');
expect(resolved.baseUrl.toString()).toBe('https://preview.test/prefix/?device=SIM-A');
}
);
it.each([
[DeviceRunSessionStatus.Stopped, { previewApiUrl: 'https://preview.test' }, 'is not running'],
[
DeviceRunSessionStatus.InProgress,
{ webPreviewUrl: 'https://expo.dev/preview' },
'does not expose a preview API',
],
[
DeviceRunSessionStatus.InProgress,
{ previewApiUrl: 'not a URL', webPreviewToken: 'preview-token' },
'invalid preview API URL',
],
[
DeviceRunSessionStatus.InProgress,
{ previewApiUrl: 'file:///private/secret', webPreviewToken: 'preview-token' },
'invalid preview API URL',
],
[
DeviceRunSessionStatus.InProgress,
{ __typename: 'ArgentRunSessionRemoteConfig', previewApiUrl: 'https://preview.test' },
'does not expose a preview API',
],
[
DeviceRunSessionStatus.InProgress,
{
__typename: 'ArgentRunSessionRemoteConfig',
previewApiUrl: 'https://preview.test/?token=embedded',
},
'does not expose a preview API',
],
])('rejects unavailable or invalid preview configuration %#', async (status, config, message) => {
await expect(resolveSimulatorPreviewAsync(session(config, status))).rejects.toThrow(message);
});
});
describe(fetchSimulatorPreviewAsync, () => {
it('requests the route on the preview API origin with header authentication', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('{}'));
await fetchSimulatorPreviewAsync(preview, '/logs', { query: { snapshot: '1' } });
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('authorization')).toBe('Bearer secret-token');
});
it('rejects redirects before sending preview credentials to another server', async () => {
const destination = jest.fn();
const target = createServer((request, response) => {
destination(request.headers.authorization);
response.end('{}');
});
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
const source = createServer((_request, response) => {
response.writeHead(302, {
location: `http://127.0.0.1:${(target.address() as AddressInfo).port}/logs`,
});
response.end();
});
await new Promise<void>(resolve => source.listen(0, '127.0.0.1', resolve));
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
try {
await expect(
fetchSimulatorPreviewAsync(
{
...preview,
baseUrl: new URL(`http://127.0.0.1:${(source.address() as AddressInfo).port}`),
},
'/logs'
)
).rejects.toThrow('Could not connect');
expect(destination).not.toHaveBeenCalled();
} finally {
source.closeAllConnections();
target.closeAllConnections();
await Promise.all([
new Promise<void>(resolve =>
source.close(() => {
resolve();
})
),
new Promise<void>(resolve =>
target.close(() => {
resolve();
})
),
]);
}
});
it('reports an offline preview tunnel before interpreting the route failure', async () => {
jest.mocked(fetch).mockRejectedValue(
new RequestError(
'https://preview.test/?token=secret-token',
new Response(Readable.from(['secret response']), {
status: 404,
headers: { 'ngrok-error-code': 'ERR_NGROK_3200' },
})
)
);
await expect(
fetchSimulatorPreviewAsync(preview, '/logs', { notFoundMessage: 'Not found here.' })
).rejects.toThrow(
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
);
});
it.each([
[401, 'refused access'],
[403, 'refused access'],
[404, 'does not support this request'],
[500, 'HTTP 500'],
])('reports HTTP %s without exposing response bodies or credentials', async (status, message) => {
jest
.mocked(fetch)
.mockRejectedValue(
new RequestError(
'https://preview.test/?token=secret-token',
new Response(Readable.from(['secret response']), { status })
)
);
const error = fetchSimulatorPreviewAsync(preview, '/logs');
await expect(error).rejects.toThrow(message);
await expect(error).rejects.not.toThrow('secret');
});
it('closes the connection of an unfinished error response and keeps its body out of the error', async () => {
const server = createServer((_request, response) => {
response.writeHead(403, { 'content-type': 'text/plain' });
response.write('secret response that never ends');
});
const closed = new Promise<void>(resolve => {
server.once('connection', socket => socket.once('close', resolve));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
let timeout: NodeJS.Timeout | undefined;
try {
const error = fetchSimulatorPreviewJsonAsync(
{
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
},
'/logs',
{ query: { snapshot: '1' } }
);
await expect(error).rejects.toThrow('refused access');
await expect(error).rejects.not.toThrow('secret');
await Promise.race([
closed,
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('The error response connection stayed open.'));
}, 1000);
}),
]);
} finally {
clearTimeout(timeout);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
it('uses the caller message for a missing route', async () => {
rejectWithStatus(404);
await expect(
fetchSimulatorPreviewAsync(preview, '/crashes/old-id', {
notFoundMessage: 'The crash report was not found.',
})
).rejects.toThrow('The crash report was not found.');
});
it('does not expose URLs in connection errors', async () => {
jest.mocked(fetch).mockRejectedValueOnce(new Error('https://preview.test/?token=secret-token'));
await expect(fetchSimulatorPreviewAsync(preview, '/logs')).rejects.toThrow(
'Could not connect to the simulator preview API.'
);
});
});
describe(fetchSimulatorPreviewJsonAsync, () => {
it('requests JSON and returns the parsed response', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('{"lines":[]}'));
await expect(
fetchSimulatorPreviewJsonAsync(preview, '/logs', { query: { snapshot: '1' } })
).resolves.toEqual({ lines: [] });
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('accept')).toBe('application/json');
});
it('passes the caller message for a missing route', async () => {
rejectWithStatus(404);
await expect(
fetchSimulatorPreviewJsonAsync(preview, '/crashes/old-id', {
notFoundMessage: 'The crash report was not found.',
})
).rejects.toThrow('The crash report was not found.');
});
it('does not expose response data in invalid JSON errors', async () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('secret-token invalid json'));
const error = fetchSimulatorPreviewJsonAsync(preview, '/logs');
await expect(error).rejects.toThrow('Could not read the simulator preview API response.');
await expect(error).rejects.not.toThrow('secret-token');
});
});
describe(streamSimulatorPreviewAsync, () => {
const signal = new AbortController().signal;
function getRequestSignal(): AbortSignal | undefined {
return jest.mocked(fetch).mock.calls[0][1]?.signal ?? undefined;
}
it('parses SSE across chunk boundaries and reports the server closing the stream', async () => {
const body = Readable.from([
': heartbeat\r\ndata: fir',
'st\r\ndata: second\r\n\r\ndata: last\n\n',
]);
jest
.mocked(fetch)
.mockResolvedValue(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
const onData = jest.fn();
await expect(streamSimulatorPreviewAsync(preview, '/logs', onData, { signal })).rejects.toThrow(
'The simulator preview stream ended unexpectedly.'
);
expect(onData.mock.calls).toEqual([['first\nsecond'], ['last']]);
expect(getRequestSignal()?.aborted).toBe(true);
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
expect(headers.get('accept')).toBe('text/event-stream');
});
it('preserves setup errors when a follow request is refused', async () => {
rejectWithStatus(403);
await expect(
streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal })
).rejects.toThrow('refused access');
});
it('stops when the signal aborts and releases the response', async () => {
const body = new PassThrough();
jest.mocked(fetch).mockImplementationOnce(async (_url, init) => {
init?.signal?.addEventListener('abort', () => body.destroy(new Error('aborted')));
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
});
const abortController = new AbortController();
const onData = jest.fn(() => {
abortController.abort();
});
const following = streamSimulatorPreviewAsync(preview, '/logs', onData, {
signal: abortController.signal,
});
body.write('data: marker\n\n');
await following;
expect(onData).toHaveBeenCalledWith('marker');
expect(body.destroyed).toBe(true);
});
it('stops when the signal aborts while waiting for response headers', async () => {
const abortController = new AbortController();
jest.mocked(fetch).mockImplementationOnce(
async (_url, init) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new Error('aborted'));
});
process.nextTick(() => {
abortController.abort();
});
})
);
const onData = jest.fn();
await streamSimulatorPreviewAsync(preview, '/logs', onData, {
signal: abortController.signal,
});
expect(onData).not.toHaveBeenCalled();
});
it('reports a stream that fails while reading without echoing its data', async () => {
const body = new PassThrough();
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(body, { headers: { 'content-type': 'text/event-stream' } })
);
const following = streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal });
body.destroy(new Error('secret-token'));
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
await expect(following).rejects.not.toThrow('secret-token');
});
it('rethrows handler errors unchanged and closes the connection', async () => {
jest.mocked(fetch).mockResolvedValueOnce(
new Response(Readable.from(['data: frame\n\n']), {
headers: { 'content-type': 'text/event-stream' },
})
);
await expect(
streamSimulatorPreviewAsync(
preview,
'/logs',
() => {
throw new Error('Handler failed.');
},
{ signal }
)
).rejects.toThrow('Handler failed.');
expect(getRequestSignal()?.aborted).toBe(true);
});
it('reports a stream that goes silent after an event once the idle timeout passes', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.write(':\n\ndata: marker\n\n');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const remote = {
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
};
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
try {
let received: () => void = () => {};
const receivedMarker = new Promise<void>(resolve => {
received = resolve;
});
const following = streamSimulatorPreviewAsync(remote, '/logs', received, { signal });
await receivedMarker;
jest.advanceTimersByTime(60_000);
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
} finally {
jest.useRealTimers();
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
it('closes the upstream connection after rejecting a response that is not an event stream', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.write('body that never ends');
});
const closed = new Promise<void>(resolve => {
server.once('connection', socket => socket.once('close', resolve));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const remote = {
...preview,
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
};
jest
.mocked(fetch)
.mockImplementationOnce(
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
);
let timeout: NodeJS.Timeout | undefined;
try {
await expect(
streamSimulatorPreviewAsync(remote, '/logs', jest.fn(), { signal })
).rejects.toThrow('does not support streaming');
await Promise.race([
closed,
new Promise<void>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('The upstream connection stayed open.'));
}, 1000);
}),
]);
} finally {
clearTimeout(timeout);
server.closeAllConnections();
await new Promise<void>(resolve =>
server.close(() => {
resolve();
})
);
}
});
});
@@ -22,6 +22,7 @@ import {
getPreviewApiAccess,
getRemoteSessionEnvironmentVariables,
sanitizeRemoteConfigForJson,
stripTerminalControlCharacters,
} from '../utils';
const agentDeviceConfig = {
@@ -966,3 +967,19 @@ describe('embed instructions', () => {
expect(formatRemoteSessionInstructions(config, 'dotenv')).not.toContain('tok-1');
});
});
describe(stripTerminalControlCharacters, () => {
it('removes terminal escapes and all control characters by default', () => {
expect(stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld\u009b')).toBe(
'helloworld'
);
});
it('keeps newlines and tabs when requested', () => {
expect(
stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld', {
keepNewlinesAndTabs: true,
})
).toBe('hello\n\tworld');
});
});
+119
View File
@@ -0,0 +1,119 @@
import path from 'node:path';
import { downloadSimulatorFileAsync } from './download';
import { stripTerminalControlCharacters } from './utils';
import fetch from '../fetch';
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../graphql/generated';
import Log from '../log';
import { ora } from '../ora';
import { formatBytes } from '../utils/files';
import { promptAsync, selectAsync } from '../prompts';
import { printJsonOnlyOutput } from '../utils/json';
export type SimulatorSession = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
export function hasSimulatorArtifacts(session: SimulatorSession): boolean {
return (
session.status === DeviceRunSessionStatus.Stopped ||
session.status === DeviceRunSessionStatus.Errored
);
}
export async function downloadSimulatorArtifactAsync(
session: SimulatorSession,
type: string,
{
artifact: index,
output,
nonInteractive,
json,
}: { artifact?: number; output?: string; nonInteractive: boolean; json: boolean }
): Promise<void> {
const artifacts = session.artifacts
.filter(artifact => artifact.metadata?.__eas_type === type)
.sort(
(a, b) =>
Date.parse(a.createdAt) - Date.parse(b.createdAt) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id)
);
if (artifacts.length === 0) {
throw new Error(
'No matching artifacts are available for this session. Artifacts may still be uploading or may not have been recorded. Run `eas simulator:get` to check the session, then try again.'
);
}
const titles = artifacts.map((artifact, i) => {
const metadata = artifact.metadata;
const device = metadata?.deviceName ?? metadata?.udid;
const part = metadata?.part;
return stripTerminalControlCharacters(
`${i + 1}. ${artifact.name} — ${artifact.filename} (${formatBytes(artifact.fileSizeBytes ?? 0)})${device ? ` (${device})` : ''}${part !== undefined ? ` (part ${part})` : ''}`
);
});
const selectionMessage = `Choose an artifact with --artifact <index>:\n${titles.join('\n')}`;
let selectedIndex = index;
if (
selectedIndex !== undefined &&
(!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > artifacts.length)
) {
throw new Error(`The artifact index is out of range. ${selectionMessage}`);
}
if (selectedIndex === undefined) {
if (artifacts.length === 1) {
selectedIndex = 1;
} else if (nonInteractive) {
throw new Error(selectionMessage);
} else {
selectedIndex = await selectAsync(
'Select an artifact to download:',
titles.map((title, i) => ({ title, value: i + 1 }))
);
}
}
const artifact = artifacts[selectedIndex - 1];
if (!output) {
if (nonInteractive) {
throw new Error('Pass --output <path> to save the session artifact in non-interactive mode.');
}
const { filePath } = await promptAsync({
type: 'text',
name: 'filePath',
message: 'Where should the artifact be saved?',
initial: stripTerminalControlCharacters(path.basename(artifact.filename)),
validate: value => value.trim().length > 0 || 'Enter an output path.',
});
output = typeof filePath === 'string' ? filePath : undefined;
}
if (!output) {
throw new Error('Enter an output path.');
}
const spinner = json ? undefined : ora('Downloading session artifact').start();
let filePath: string;
try {
filePath = await downloadSimulatorFileAsync(output, async signal => {
try {
return await fetch(artifact.downloadUrl, { signal, timeout: 30_000 });
} catch {
throw new Error(
'Could not download the session artifact. Check your internet connection and try again.'
);
}
});
spinner?.succeed();
} catch (error) {
spinner?.fail();
throw error;
}
if (json) {
const { id, name, filename, metadata } = artifact;
printJsonOnlyOutput({
deviceRunSessionId: session.id,
artifact: { id, name, filename, metadata },
filePath,
});
} else {
Log.log(
`Saved ${stripTerminalControlCharacters(artifact.name)} to ${stripTerminalControlCharacters(filePath)}.`
);
}
}
@@ -0,0 +1,65 @@
import fs from 'fs-extra';
import { open } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type Response } from '../fetch';
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
export async function downloadSimulatorFileAsync(
output: string,
getResponse: (signal: AbortSignal) => Promise<Response>
): Promise<string> {
const outputPath = path.resolve(output);
if (await fs.pathExists(outputPath)) {
throw new Error(
'The output file already exists. The command does not overwrite files. Choose another --output path.'
);
}
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]);
const interruptHandler = (): void => {
controller.abort();
};
let response: Response | undefined;
let created = false;
process.on('SIGINT', interruptHandler);
try {
response = await getResponse(signal);
const file = await open(outputPath, 'wx', 0o600);
created = true;
try {
await pipeline(response.body, file.createWriteStream(), { signal });
} finally {
await file.close();
}
return outputPath;
} catch (error) {
if (created) {
await fs.remove(outputPath);
}
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
throw new Error(
'The output file already exists. The command does not overwrite files. Choose another --output path.'
);
}
if (controller.signal.aborted) {
throw new Error('The download was canceled.');
}
if (signal.aborted) {
throw new Error(
'The download timed out. It did not finish within 10 minutes. Check your internet connection, then run the command again.'
);
}
if (!response) {
throw error;
}
throw new Error(
'Could not save the download. Check that the --output directory exists and is writable, then try again.'
);
} finally {
controller.abort();
process.removeListener('SIGINT', interruptHandler);
}
}
+1 -11
View File
@@ -1,7 +1,6 @@
import { stripVTControlCharacters } from 'util';
import { type DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '@expo/eas-build-job';
import { stripTerminalControlCharacters } from './utils';
import fetch, { RequestError } from '../fetch';
import { formatMilliseconds } from '../utils/timer';
@@ -109,12 +108,3 @@ function getScreenshotFilename(event: DeviceRunSessionEvent): string | undefined
}
return event.data.path.split(/[\\/]/).at(-1);
}
function stripTerminalControlCharacters(value: string): string {
return [...stripVTControlCharacters(value)]
.filter(character => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 0x20 && !(codePoint >= 0x7f && codePoint <= 0x9f);
})
.join('');
}
+201
View File
@@ -0,0 +1,201 @@
import readline from 'node:readline';
import { getPreviewApiAccess } from './utils';
import { type SimulatorSession } from './artifacts';
import fetch, { RequestError, type Response } from '../fetch';
import { DeviceRunSessionStatus } from '../graphql/generated';
const PREVIEW_API_TIMEOUT_MS = 30_000;
// The preview server sends a heartbeat every 15 seconds, so a longer silence means the stream is gone.
const PREVIEW_STREAM_IDLE_TIMEOUT_MS = 60_000;
const INVALID_PREVIEW_API_URL_MESSAGE =
'The simulator session has an invalid preview API URL. The session reported a URL that is not a valid HTTP(S) URL. Start a new session with `eas simulator:start`.';
const PREVIEW_STREAM_ENDED_MESSAGE =
"The simulator preview stream ended unexpectedly. The connection to the session's preview server closed. Run `eas simulator:get` to check that the session is still running, then run the command again.";
const PREVIEW_DATA_NOT_FOUND_MESSAGE =
"The requested preview data was not found. The session's preview server does not support this request. Start a new session with `eas simulator:start`, then try again.";
export type SimulatorPreview = {
deviceRunSessionId: string;
baseUrl: URL;
token: string;
};
type SimulatorPreviewRequestOptions = {
query?: Record<string, string>;
signal?: AbortSignal;
/** Message for a 404 response. Defaults to a generic message for unsupported preview data. */
notFoundMessage?: string;
};
export async function resolveSimulatorPreviewAsync(
session: SimulatorSession
): Promise<SimulatorPreview> {
if (session.status !== DeviceRunSessionStatus.InProgress) {
throw new Error(
'The simulator session is not running. Live preview data is only available while a session runs. Start a new session with `eas simulator:start`.'
);
}
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
if (!access) {
throw new Error(
'This simulator session does not expose a preview API. The command reads live data through the preview API, and the session did not report its URL and token. Start a new iOS session with `eas simulator:start --platform ios`.'
);
}
let baseUrl: URL;
try {
baseUrl = new URL(access.apiUrl);
} catch {
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
}
if (!['https:', 'http:'].includes(baseUrl.protocol)) {
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
}
// Requests send the token in a header, so keep it out of URLs that can appear in errors.
baseUrl.searchParams.delete('token');
baseUrl.hash = '';
return { deviceRunSessionId: session.id, baseUrl, token: access.token };
}
export async function fetchSimulatorPreviewAsync(
preview: SimulatorPreview,
route: string,
{
query,
signal,
notFoundMessage = PREVIEW_DATA_NOT_FOUND_MESSAGE,
accept,
}: SimulatorPreviewRequestOptions & { accept?: string } = {}
): Promise<Response> {
const url = new URL(route, preview.baseUrl);
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value);
}
// Aborting closes the connection of an error response, whose body is never read.
const controller = new AbortController();
try {
return await fetch(url.toString(), {
timeout: PREVIEW_API_TIMEOUT_MS,
redirect: 'error',
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
headers: {
Authorization: `Bearer ${preview.token}`,
...(accept ? { Accept: accept } : {}),
},
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
if (error instanceof RequestError) {
controller.abort();
if (error.response.headers.get('ngrok-error-code') === 'ERR_NGROK_3200') {
throw new Error(
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
);
}
const { status } = error.response;
if (status === 401 || status === 403) {
throw new Error(
"The preview API refused access. It did not accept the session's preview token. Run `eas simulator:get` to check that the session is still running, then try again."
);
}
if (status === 404) {
throw new Error(notFoundMessage);
}
throw new Error(
`The preview API request failed (HTTP ${status}). The session's preview server returned an error. Run \`eas simulator:get\` to check that the session is still running, then try again.`
);
}
throw new Error(
"Could not connect to the simulator preview API. The request did not reach the session's preview server. Check your internet connection. Run `eas simulator:get` to check that the session is still running."
);
}
}
export async function fetchSimulatorPreviewJsonAsync<T>(
preview: SimulatorPreview,
route: string,
options: Omit<SimulatorPreviewRequestOptions, 'signal'> = {}
): Promise<T> {
const response = await fetchSimulatorPreviewAsync(preview, route, {
...options,
accept: 'application/json',
});
try {
return (await response.json()) as T;
} catch {
throw new Error(
'Could not read the simulator preview API response. The response was incomplete or not valid JSON. Try again.'
);
}
}
/**
* Calls `onData` with each server-sent event's data until `signal` aborts. The stream ending is an error.
* Errors thrown by `onData` stop the stream and are rethrown unchanged.
*/
export async function streamSimulatorPreviewAsync(
preview: SimulatorPreview,
route: string,
onData: (data: string) => void,
{ signal, ...options }: SimulatorPreviewRequestOptions & { signal: AbortSignal }
): Promise<void> {
// Aborting closes the connection, including when the stream stops because of an error.
const controller = new AbortController();
let reading = false;
let onDataError: unknown;
let idleTimer: NodeJS.Timeout | undefined;
const resetIdleTimer = (): void => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
controller.abort();
}, PREVIEW_STREAM_IDLE_TIMEOUT_MS);
};
try {
const response = await fetchSimulatorPreviewAsync(preview, route, {
...options,
signal: AbortSignal.any([signal, controller.signal]),
accept: 'text/event-stream',
});
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
throw new Error(
'This simulator session does not support streaming. Its preview server did not return an event stream. Run the command without --follow.'
);
}
reading = true;
resetIdleTimer();
let data: string[] = [];
for await (const line of readline.createInterface({
input: response.body,
crlfDelay: Infinity,
})) {
resetIdleTimer();
if (line === '' && data.length > 0) {
const frame = data.join('\n');
data = [];
try {
onData(frame);
} catch (error) {
onDataError = error;
throw error;
}
} else if (line.startsWith('data:')) {
data.push(line.slice(5).replace(/^ /, ''));
}
}
// The server keeps the stream open while the session runs, so a clean end is unexpected too.
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
} catch (error) {
if (signal.aborted) {
return;
}
if (!reading || error === onDataError) {
throw error;
}
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
} finally {
clearTimeout(idleTimer);
controller.abort();
}
}
+52
View File
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { stripVTControlCharacters } from 'util';
import {
AppPlatform,
@@ -655,3 +656,54 @@ function formatControllerInstructions(
].join('\n');
}
}
// Session text is untrusted, so strip terminal escape and control characters before printing it.
export function stripTerminalControlCharacters(
value: string,
{ keepNewlinesAndTabs = false }: { keepNewlinesAndTabs?: boolean } = {}
): string {
return stripVTControlCharacters(value).replace(/\p{Cc}/gu, character =>
keepNewlinesAndTabs && (character === '\n' || character === '\t') ? character : ''
);
}
export function isValidLogTime(at: unknown): at is number {
return typeof at === 'number' && !Number.isNaN(new Date(at).getTime());
}
export function formatLogLine(raw: string, timestamp = false, at = Date.now()): string {
const time = new Date(isValidLogTime(at) ? at : Date.now()).toISOString();
try {
const entry = JSON.parse(raw) as {
timestamp?: string;
processImagePath?: string;
processID?: number;
eventMessage?: string;
messageType?: string | number;
};
if (typeof entry.eventMessage === 'string') {
const prefix = timestamp
? `${chalk.dim(stripTerminalControlCharacters(entry.timestamp ?? time))} `
: '';
const processName = entry.processImagePath?.split('/').at(-1) ?? 'unknown';
const processId = typeof entry.processID === 'number' ? `:${entry.processID}` : '';
const processLabel = chalk.cyan(
stripTerminalControlCharacters(`[${processName}${processId}]`)
);
const level = String(entry.messageType ?? '').toLowerCase();
let message = stripTerminalControlCharacters(entry.eventMessage, {
keepNewlinesAndTabs: true,
});
if (level === 'error' || level === '16') {
message = chalk.red(message);
} else if (level === 'fault' || level === '17') {
message = chalk.red.bold(message);
} else if (level === 'debug' || level === '2') {
message = chalk.dim(message);
}
return `${prefix}${processLabel} ${message}`;
}
} catch {}
const prefix = timestamp ? `${chalk.dim(time)} ` : '';
return `${prefix}${stripTerminalControlCharacters(raw, { keepNewlinesAndTabs: true })}`;
}
@@ -0,0 +1,97 @@
import chalk from 'chalk';
import { Permission, Role } from '../graphql/generated';
import Log from '../log';
import { Actor } from './User';
import { getRoleForPermissions } from './roles';
/**
* Logs the actor's role on each account, and whether the credentials used for this command limit
* it. The list is skipped when it only contains the personal account and the credentials do not
* limit it.
*
* @returns whether the credentials limit the permissions on any account
*/
export function logAccountPermissions(actor: Actor): { hasLimitedAccount: boolean } {
// 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(isLimitedByCredentials);
if (accountExcludingPersonalAccount.length === 0 && !hasLimitedAccount) {
return { hasLimitedAccount };
}
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
const roleLabel = getLabelForPermissions(permissions);
if (isLimitedByCredentials(account)) {
const effectiveRoleLabel = getLabelForPermissions(effectivePermissions);
Log.log(
`• ${account.name} (Role: ${roleLabel}, ${chalk.yellow(
`limited to ${effectiveRoleLabel} by the current credentials`
)})`
);
} else {
Log.log(`• ${account.name} (Role: ${roleLabel})`);
}
});
return { hasLimitedAccount };
}
/**
* Whether the scopes of the credential used for this command remove any of the permissions that
* the viewer's role grants on the account.
*/
function isLimitedByCredentials(account: Actor['accounts'][0]): boolean {
const { permissions, effectivePermissions } = account.viewerUserPermission2;
return permissions.some(permission => !effectivePermissions.includes(permission));
}
function getLabelForPermissions(permissions: readonly Permission[]): string {
const role = getRoleForPermissions(permissions);
if (role !== Role.Custom) {
return getLabelForRole(role);
}
if (permissions.length === 0) {
return 'No permissions';
}
return `Custom: ${permissions
.map(permission => permission.toLowerCase().replaceAll('_', ' '))
.join(', ')}`;
}
function 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(' ');
}
}
}
+10
View File
@@ -1,5 +1,15 @@
# @expo/eas-json
## 27.0.0
No changes in this release.
## 26.0.0
### Minor Changes
- Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
## 25.3.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.3.0",
"version": "27.0.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-build-job": "27.0.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
@@ -17,6 +17,7 @@ module Pod
%r{https?://(api\.)?github\.com/},
%r{https?://sourceforge\.net/},
%r{https?://repo1\.maven\.org/},
%r{https?://central\.sonatype\.com/},
%r{https?://boostorg\.jfrog\.io/},
%r{https?://www\.sqlite\.org/},
%r{https?://download\.videolan\.org/},
@@ -142,6 +142,17 @@ RSpec.describe ExpoCocoaPodsProxy do
downloader.download_file(path.to_s)
end
it 'fetches Sonatype snapshot artifacts via proxy' do
path = Pathname.new('some/fake/path')
url = 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/react/react-native-artifacts/0.82.0-SNAPSHOT/maven-metadata.xml'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:download_file_via_proxy).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when url doesn\'t match an allowlist regex' do
path = Pathname.new('some/fake/path')
url = 'https://not-github.com/expo/test-repo/archive/main.tar.gz'
+8
View File
@@ -1,5 +1,13 @@
# eas-cli-local-build-plugin
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.3.0",
"version": "27.0.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "25.3.0",
"@expo/build-tools": "27.0.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.3.0",
"@expo/logger": "25.3.0",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "27.0.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+8
View File
@@ -1,5 +1,13 @@
# @expo/logger
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "25.3.0",
"version": "27.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more