mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e192ecba6 | ||
|
|
864b11effe | ||
|
|
62df1bae1c | ||
|
|
8070663faa | ||
|
|
e38a5a88d2 | ||
|
|
82845def18 | ||
|
|
476b4e9b09 | ||
|
|
9315aebef5 | ||
|
|
786d8bac85 | ||
|
|
8f67105e5a | ||
|
|
3bfb405c87 | ||
|
|
73335e8233 | ||
|
|
9af03f901e | ||
|
|
13ab40e271 | ||
|
|
355f3f2012 | ||
|
|
736e1aeb90 | ||
|
|
58d372a096 | ||
|
|
56d01c4129 | ||
|
|
bcb5d96ea8 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
"STAGING": "26.0.0",
|
||||
"PRODUCTION": "26.0.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+11
-1
@@ -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({
|
||||
|
||||
+13
-1
@@ -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',
|
||||
});
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+43
-1
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
+53
-2
@@ -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', {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
+37
@@ -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'));
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,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('');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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]>",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 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": "@expo/template-file",
|
||||
"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 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 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": "@expo/turtle-spawn",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "27.0.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "25.3.0",
|
||||
"version": "27.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "25.3.0",
|
||||
"@expo/build-tools": "27.0.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@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/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@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",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user