mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af23af8e17 | ||
|
|
efbbe5bc19 | ||
|
|
574e3883e3 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@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/build-tools': patch
|
||||
---
|
||||
|
||||
Keep the launched app open when an Appium iOS session starts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Run Appium through the prepared Full Disk Access helper when available on macOS.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient failures when reporting that a device run session has started.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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': major
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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
|
||||
---
|
||||
|
||||
Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `code` to the `--json` error: `EAS_PROJECT_NOT_CONFIGURED` when the project is not linked to EAS, and `EAS_LOGIN_REQUIRED` when a non-interactive run is not logged in.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Print `{"error": {"message", "requestId"?}}` on stdout when a command run with `--json` fails, and keep all other output off stdout from the start of the command.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show account roles and credential permission limits in `eas login` when you are already logged in.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Identify the build or application archive when a download fails, and explain when the archive is no longer available.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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': patch
|
||||
---
|
||||
|
||||
Run apsd setup in the background without delaying iOS app launch or preview readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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
|
||||
---
|
||||
|
||||
Fix simulator control action help routing and show help when a flag is incomplete.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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': patch
|
||||
---
|
||||
|
||||
Handle a device session stopped during startup as normal cleanup instead of reporting a startup error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/eas-build-job': patch
|
||||
---
|
||||
|
||||
Add types and validation for documented GitHub workflow context fields.
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
|
||||
@@ -2,38 +2,6 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [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": "26.0.0",
|
||||
"PRODUCTION": "26.0.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,24 +1,5 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 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": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "26.0.0",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "26.0.0",
|
||||
"@expo/template-file": "26.0.0",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.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('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -162,59 +162,6 @@ 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({
|
||||
|
||||
+1
-11
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -176,16 +176,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('releases resources without monitoring when the session stopped during startup', async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await run();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
|
||||
+1
-13
@@ -103,18 +103,6 @@ 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();
|
||||
|
||||
@@ -159,7 +147,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
return host;
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -203,78 +202,6 @@ 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);
|
||||
@@ -434,7 +361,6 @@ 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',
|
||||
});
|
||||
@@ -521,7 +447,6 @@ 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('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
|
||||
|
||||
+1
-43
@@ -84,52 +84,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
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({
|
||||
@@ -309,7 +268,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -312,22 +312,8 @@ 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(
|
||||
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
|
||||
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -239,9 +239,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
|
||||
@@ -65,7 +65,6 @@ 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(),
|
||||
@@ -226,12 +225,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
...(useFdaHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
@@ -246,13 +242,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
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,
|
||||
}),
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
@@ -288,7 +278,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -306,9 +296,6 @@ 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.'
|
||||
);
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -374,9 +374,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -58,11 +57,6 @@ 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,
|
||||
@@ -74,20 +68,9 @@ 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 });
|
||||
}
|
||||
@@ -146,7 +129,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
@@ -164,9 +147,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -174,14 +154,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
await sessionHost.finishAsync();
|
||||
|
||||
+2
-53
@@ -170,53 +170,6 @@ 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
|
||||
@@ -411,9 +364,7 @@ describe('with a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
).rejects.toThrow('Unexpected response from server (500): stop here');
|
||||
|
||||
expectWorkerApiCall('application', 'GET');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
@@ -638,9 +589,7 @@ describe('without a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
).rejects.toThrow('Unexpected response from server (500)');
|
||||
|
||||
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])(
|
||||
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
|
||||
'waits for setup before launch or boot-only readiness (app: %s)',
|
||||
async hasApp => {
|
||||
const setup = deferred<void>();
|
||||
const prepared = deferred<ServeSimApplicationOptions | void>();
|
||||
@@ -1468,78 +1468,70 @@ it.each([true, false])(
|
||||
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
|
||||
: undefined
|
||||
);
|
||||
if (hasApp) {
|
||||
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
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));
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
|
||||
} else {
|
||||
expect(reported).toBe(false);
|
||||
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(
|
||||
hasApp
|
||||
? [
|
||||
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
|
||||
[
|
||||
'app.launch',
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
bundleId: 'dev.example.other',
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
: []
|
||||
).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()
|
||||
);
|
||||
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 () => {
|
||||
queried = true;
|
||||
return { status: 'ready', device: 'emulator-5554' };
|
||||
},
|
||||
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
|
||||
} as never);
|
||||
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();
|
||||
}
|
||||
const host = await starting;
|
||||
await host.finishAsync();
|
||||
await expect(access(directory)).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1684,8 +1676,7 @@ it('warns and continues launching when disabling push fails', async () => {
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('cancels pending app preparation and observes late setup failure', async () => {
|
||||
const application = deferred<ServeSimApplicationOptions>();
|
||||
it('cancels pending setup without launching and observes its late failure', async () => {
|
||||
const controller = new AbortController();
|
||||
let rejectSetup!: (error: Error) => void;
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
|
||||
@@ -1699,7 +1690,7 @@ it('cancels pending app preparation and observes late setup failure', async () =
|
||||
logger,
|
||||
timeoutMs: 2_000,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
application: application.promise,
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(starting).rejects.toThrow('session canceled');
|
||||
|
||||
@@ -91,8 +91,7 @@ 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,
|
||||
readonly code?: string
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
@@ -268,9 +267,7 @@ export class DeviceRunSessionWorkerApi {
|
||||
: response.statusText;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
response.status
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
|
||||
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
let beforeLaunch: 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 simulatorSetup;
|
||||
await beforeLaunch;
|
||||
if (stagedAppDirectory) {
|
||||
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -711,8 +711,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
|
||||
simulatorSetup = asyncResult(
|
||||
beforeLaunch = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
@@ -750,6 +749,7 @@ 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,17 +31,14 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
const sessionStart = await uploadRemoteSessionConfigAsync({
|
||||
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.
|
||||
@@ -60,5 +57,4 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
|
||||
});
|
||||
}
|
||||
return sessionStart;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
type DeviceRunSessionStatus,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from './deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -555,7 +554,7 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
@@ -565,16 +564,7 @@ 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,9 +1,5 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 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": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "26.0.0"
|
||||
"@expo/steps": "25.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 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": "26.0.0",
|
||||
"version": "25.3.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,15 +1,5 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 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": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -871,7 +871,7 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
});
|
||||
|
||||
describe('GitHub context event payload passthrough', () => {
|
||||
it('accepts arbitrary fields at all event levels', () => {
|
||||
it('preserves webhook fields and validates documented pull request details', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
@@ -895,7 +895,19 @@ describe('GitHub context event payload passthrough', () => {
|
||||
},
|
||||
pull_request: {
|
||||
number: 123,
|
||||
title: 'Fix the build',
|
||||
body: null,
|
||||
state: 'open',
|
||||
draft: true,
|
||||
merged: false,
|
||||
html_url: 'https://github.com/expo/expo/pull/123',
|
||||
user: { login: 'octocat', id: 1 },
|
||||
labels: [{ name: 'bug', color: 'red' }],
|
||||
head: { ref: 'fix-build', sha: 'abc123', repo: { name: 'expo' } },
|
||||
base: { ref: 'main', sha: 'def456' },
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
updated_at: '2026-10-02T00:00:00Z',
|
||||
merged_at: null,
|
||||
},
|
||||
number: 123,
|
||||
schedule: '0 0 * * *',
|
||||
@@ -921,6 +933,34 @@ describe('GitHub context event payload passthrough', () => {
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
|
||||
expect(parsed.github?.event).toMatchObject(context.github.event);
|
||||
expect(() =>
|
||||
StaticWorkflowInterpolationContextZ.parse({
|
||||
...context,
|
||||
github: {
|
||||
...context.github,
|
||||
event: {
|
||||
...context.github.event,
|
||||
pull_request: { ...context.github.event.pull_request, title: 123 },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ number: 123 },
|
||||
{ number: 123, body: null, user: null, merged: null, merged_at: null },
|
||||
])('accepts partial pull request payloads: %j', pullRequest => {
|
||||
const github = {
|
||||
event_name: 'pull_request',
|
||||
sha: 'abc123',
|
||||
ref: 'refs/heads/main',
|
||||
ref_name: 'main',
|
||||
ref_type: 'branch',
|
||||
event: { pull_request: pullRequest },
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.shape.github.parse(github)).toEqual(github);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ const GitHubContextZ = z.object({
|
||||
.record(z.string(), z.unknown())
|
||||
.and(
|
||||
z.object({
|
||||
action: z.string().optional(),
|
||||
label: z
|
||||
.looseObject({
|
||||
name: z.string(),
|
||||
@@ -221,6 +222,19 @@ const GitHubContextZ = z.object({
|
||||
pull_request: z
|
||||
.looseObject({
|
||||
number: z.number(),
|
||||
title: z.string().optional(),
|
||||
body: z.string().nullable().optional(),
|
||||
state: z.enum(['open', 'closed']).optional(),
|
||||
draft: z.boolean().optional(),
|
||||
merged: z.boolean().nullable().optional(),
|
||||
html_url: z.string().optional(),
|
||||
user: z.looseObject({ login: z.string() }).nullable().optional(),
|
||||
labels: z.array(z.looseObject({ name: z.string() })).optional(),
|
||||
head: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
|
||||
base: z.looseObject({ ref: z.string(), sha: z.string() }).optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
merged_at: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
number: z.number().optional(),
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# eas-cli
|
||||
|
||||
## 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
|
||||
|
||||
+198
-198
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "26.0.0",
|
||||
"version": "25.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/eas-json": "26.0.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/eas-json": "25.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "26.0.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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,5 +1,6 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
@@ -8,7 +9,7 @@ describe(EasHelp, () => {
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(config, 'runCommand').mockResolvedValue(Promise.resolve());
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
@@ -20,14 +21,12 @@ 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('simulator:control', argv.slice(1));
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
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,6 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EasBuildUnauthorizedError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
@@ -760,38 +758,6 @@ 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,7 +25,6 @@ import {
|
||||
EasBuildProjectArchiveUploadError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EasBuildUnauthorizedError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
@@ -48,7 +47,6 @@ import {
|
||||
logSourceForBuild,
|
||||
} from './logs';
|
||||
import { collectMetadataAsync } from './metadata';
|
||||
import { resolveJobExpoTokenRole } from './utils/expoTokenRole';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
LocalFile,
|
||||
@@ -132,7 +130,6 @@ function resolveBuildParamsInput<T extends Platform>(
|
||||
resourceClass: ctx.resourceClass,
|
||||
sdkVersion: metadata.sdkVersion,
|
||||
reactNativeVersion: metadata.reactNativeVersion,
|
||||
expoTokenRole: resolveJobExpoTokenRole(ctx.buildProfile),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,17 +300,6 @@ 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,6 +22,4 @@ export class EasBuildDownForMaintenanceError extends EasCommandError {}
|
||||
|
||||
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
|
||||
|
||||
export class EasBuildUnauthorizedError extends EasCommandError {}
|
||||
|
||||
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
|
||||
@@ -1,33 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command, Errors } from '@oclif/core';
|
||||
import { Command } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
@@ -31,7 +31,6 @@ import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { getActorDisplayName } from '../user/User';
|
||||
import { enableJsonOutput, printJsonErrorOutput } from '../utils/json';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type ContextInput<
|
||||
@@ -258,24 +257,9 @@ export default abstract class EasCommand extends Command {
|
||||
action: `eas ${this.id}`,
|
||||
});
|
||||
|
||||
// Before the context is resolved, so its messages and errors stay off stdout too.
|
||||
if (this.jsonOutputRequested()) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
return await this.runAsync();
|
||||
}
|
||||
|
||||
/** Mirrors oclif's `jsonEnabled`, for commands that declare their own `json` flag. */
|
||||
private jsonOutputRequested(): boolean {
|
||||
if (!this.ctor.flags?.json) {
|
||||
return false;
|
||||
}
|
||||
const jsonIndex = this.argv.indexOf('--json');
|
||||
const passThroughIndex = this.argv.indexOf('--');
|
||||
return jsonIndex !== -1 && (passThroughIndex === -1 || jsonIndex < passThroughIndex);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
override async finally(err: Error): Promise<any> {
|
||||
await this.analytics.flushAsync();
|
||||
@@ -286,19 +270,10 @@ export default abstract class EasCommand extends Command {
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
const commandId = this.id ?? 'unknown';
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
let message = err.message;
|
||||
let code: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
if (err instanceof Errors.ExitError) {
|
||||
// Errors.exit(1) carries no reason; the code that called it printed the reason already.
|
||||
Log.errorToStderr(err.message);
|
||||
message = baseMessage;
|
||||
} else if (err instanceof EasCommandError || err instanceof Errors.CLIError) {
|
||||
if (!(err instanceof EasCommandError && err.messageAlreadyPrinted)) {
|
||||
Log.errorToStderr(err.message);
|
||||
if (err instanceof EasCommandError) {
|
||||
if (!err.messageAlreadyPrinted) {
|
||||
Log.error(err.message);
|
||||
}
|
||||
// Only codes that eas-cli sets. GraphQL error codes belong to the server and stay out.
|
||||
code = err.code;
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanGQLErrorsMessage = err?.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
@@ -346,15 +321,10 @@ export default abstract class EasCommand extends Command {
|
||||
const cleanMessage = err.networkError
|
||||
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
|
||||
: cleanGQLErrorsMessage;
|
||||
Log.errorToStderr(cleanMessage);
|
||||
Log.error(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
message = cleanMessage;
|
||||
requestId = err.graphQLErrors[0]?.extensions?.requestId as string | undefined;
|
||||
} else {
|
||||
Log.errorToStderr(err.message);
|
||||
}
|
||||
if (this.jsonOutputRequested()) {
|
||||
printJsonErrorOutput({ message, code, requestId });
|
||||
Log.error(err.message);
|
||||
}
|
||||
Log.debug(err);
|
||||
Sentry.withScope(scope => {
|
||||
|
||||
@@ -24,7 +24,6 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
};
|
||||
});
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
@@ -201,7 +200,7 @@ describe('EasCommand', () => {
|
||||
it('logs the message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'errorToStderr');
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const error = new Error('Unexpected, internal error message');
|
||||
@@ -219,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, 'errorToStderr');
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const { EasCommandError } = require('../errors');
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
@@ -243,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, 'errorToStderr');
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
@@ -263,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, 'errorToStderr');
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
@@ -298,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, 'errorToStderr');
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
@@ -336,146 +335,6 @@ describe('EasCommand', () => {
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
describe('with --json', () => {
|
||||
const createJsonTestEasCommand = (): any => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const { Flags } = require('@oclif/core');
|
||||
TestEasCommand.flags = { json: Flags.boolean() };
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
it('enables JSON output before the command runs', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { enableJsonOutput } = jest.requireMock('../../utils/json');
|
||||
let enabledBeforeRun = false;
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(async () => {
|
||||
enabledBeforeRun = enableJsonOutput.mock.calls.length > 0;
|
||||
});
|
||||
|
||||
await TestEasCommand.run(['--json'], mockConfig);
|
||||
|
||||
expect(enabledBeforeRun).toBe(true);
|
||||
});
|
||||
|
||||
it('prints the error message', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { EasCommandError } = require('../errors');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new EasCommandError('EAS project not configured.');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith({
|
||||
message: 'EAS project not configured.',
|
||||
code: undefined,
|
||||
requestId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the code of an EasCommandError', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { EasCommandError } = require('../errors');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new EasCommandError('EAS project not configured.', {
|
||||
code: 'EAS_PROJECT_NOT_CONFIGURED',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'EAS_PROJECT_NOT_CONFIGURED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the code of an oclif error', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { Errors } = require('@oclif/core');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
Errors.error('Log in first.', { code: 'EAS_LOGIN_REQUIRED' });
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Log in first.', code: 'EAS_LOGIN_REQUIRED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the command failure, not EEXIT, for Errors.exit', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { Errors } = require('@oclif/core');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
Errors.exit(1);
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith({
|
||||
message: `${TestEasCommand.id} command failed.`,
|
||||
code: undefined,
|
||||
requestId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the GraphQL request ID, not the server error code', async () => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const { GraphQLError } = require('graphql/error');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('Entity not authorized', null, null, null, null, null, {
|
||||
errorCode: 'UNAUTHORIZED_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: mockRequestId })
|
||||
);
|
||||
expect(printJsonErrorOutput.mock.calls[0][0].code).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['without --json', []],
|
||||
['with --json after --', ['--', '--json']],
|
||||
])('prints no JSON error %s', async (_, argv) => {
|
||||
const TestEasCommand = createJsonTestEasCommand();
|
||||
const { enableJsonOutput, printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(argv, mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonErrorOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints no JSON error when the command has no json flag', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const { printJsonErrorOutput } = jest.requireMock('../../utils/json');
|
||||
jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
|
||||
await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow();
|
||||
|
||||
expect(printJsonErrorOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-throws the error with default base message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
|
||||
-37
@@ -11,16 +11,8 @@ 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, () => {
|
||||
@@ -123,32 +115,3 @@ 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'));
|
||||
});
|
||||
});
|
||||
-10
@@ -513,16 +513,6 @@ describe(getProjectIdAsync, () => {
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives the non-interactive error the EAS_PROJECT_NOT_CONFIGURED code', async () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'EAS_PROJECT_NOT_CONFIGURED' });
|
||||
});
|
||||
|
||||
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
|
||||
|
||||
+1
-2
@@ -127,8 +127,7 @@ export async function findProjectDirAndVerifyProjectSetupAsync({
|
||||
} = {}): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync({ cwd });
|
||||
await applyCliConfigAsync(projectDir);
|
||||
// The notice is advice for a person. Programs that run eas-cli read stderr for its errors.
|
||||
if (!ranEnsureEasCliIsNotInDependencies && process.stderr.isTTY) {
|
||||
if (!ranEnsureEasCliIsNotInDependencies) {
|
||||
ranEnsureEasCliIsNotInDependencies = true;
|
||||
await ensureEasCliIsNotInDependenciesAsync(projectDir);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
export class EasCommandError extends Error {
|
||||
/** True when the command already printed the message, so `EasCommand.catch` must not print it again. */
|
||||
public readonly messageAlreadyPrinted: boolean;
|
||||
/** A stable identifier, printed in the `--json` error output so programs need not match the message. */
|
||||
public readonly code?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
{ messageAlreadyPrinted = false, code }: { messageAlreadyPrinted?: boolean; code?: string } = {}
|
||||
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
|
||||
) {
|
||||
super(message);
|
||||
this.messageAlreadyPrinted = messageAlreadyPrinted;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export function isNonInteractiveByDefault(): boolean {
|
||||
export const EasNonInteractiveAndJsonFlags = {
|
||||
json: Flags.boolean({
|
||||
description:
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}. Implies --non-interactive.',
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
@@ -87,8 +87,7 @@ export const EASNonInteractiveFlag = {
|
||||
|
||||
export const EasJsonOnlyFlag = {
|
||||
json: Flags.boolean({
|
||||
description:
|
||||
'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}.',
|
||||
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -38,12 +33,12 @@ const sessionManager = new SessionManager({
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
const originalCI = process.env.CI;
|
||||
|
||||
function command(args: string[], actor: Actor | null = null): AccountLogin {
|
||||
function command(args: string[]): 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 },
|
||||
maybeLoggedIn: { actor: null },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
@@ -184,48 +179,3 @@ 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,7 +7,6 @@ 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,
|
||||
@@ -78,13 +77,6 @@ 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,9 +1,10 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Permission, Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
import { logAccountPermissions } from '../../user/accountPermissions';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
import { getRoleForPermissions } from '../../user/roles';
|
||||
|
||||
export default class AccountView extends EasCommand {
|
||||
static override description = 'show the username you are logged in as';
|
||||
@@ -27,18 +28,95 @@ export default class AccountView extends EasCommand {
|
||||
Log.log(actor.email);
|
||||
}
|
||||
|
||||
const { hasLimitedAccount } = logAccountPermissions(actor);
|
||||
if (hasLimitedAccount) {
|
||||
// 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) {
|
||||
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('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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,13 @@ 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');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
} catch {
|
||||
await super.showHelp(argv);
|
||||
return;
|
||||
}
|
||||
const parsed = await Parser.parse(args, {
|
||||
args: SimulatorControl.args,
|
||||
flags: SimulatorControl.flags,
|
||||
strict: false,
|
||||
});
|
||||
if (parsed.args.action && parsed.flags.help) {
|
||||
await this.config.runCommand('simulator:control', args);
|
||||
await SimulatorControl.run(args, this.config);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,6 @@ 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 warn(...args: any[]): void {
|
||||
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { Actor, getCreatableAccountNames } from '../user/User';
|
||||
|
||||
/**
|
||||
@@ -21,8 +20,8 @@ export function getUnconfiguredProjectError({
|
||||
accountName?: string;
|
||||
reason?: string;
|
||||
additionalFix?: string;
|
||||
}): EasCommandError {
|
||||
return new EasCommandError(
|
||||
}): Error {
|
||||
return new Error(
|
||||
`EAS project not configured. ${reason} ` +
|
||||
`Run one of the following, then re-run this command:\n\n` +
|
||||
`To link an existing project:\n\n` +
|
||||
@@ -30,7 +29,6 @@ export function getUnconfiguredProjectError({
|
||||
`To create a new project:\n\n` +
|
||||
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
|
||||
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
|
||||
(additionalFix ? `\n\n${additionalFix}` : ''),
|
||||
{ code: 'EAS_PROJECT_NOT_CONFIGURED' }
|
||||
(additionalFix ? `\n\n${additionalFix}` : '')
|
||||
);
|
||||
}
|
||||
@@ -185,8 +185,7 @@ export default class SessionManager {
|
||||
)} environment variable if you're using EAS CLI on CI (${learnMore(
|
||||
'https://docs.expo.dev/accounts/programmatic-access/',
|
||||
{ dim: false }
|
||||
)})`,
|
||||
{ code: 'EAS_LOGIN_REQUIRED' }
|
||||
)})`
|
||||
);
|
||||
}
|
||||
if (printNewLine) {
|
||||
|
||||
@@ -313,15 +313,6 @@ describe(SessionManager, () => {
|
||||
});
|
||||
|
||||
describe('showLoginPromptAsync', () => {
|
||||
it('fails with the EAS_LOGIN_REQUIRED code in non-interactive mode', async () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
await expect(
|
||||
sessionManager.showLoginPromptAsync({ nonInteractive: true })
|
||||
).rejects.toMatchObject({ code: 'EAS_LOGIN_REQUIRED' });
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for OTP when 2FA is enabled', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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,36 +0,0 @@
|
||||
describe('printJsonErrorOutput', () => {
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
it('prints one error object on stdout, without ANSI codes, once per process', () => {
|
||||
const stdout = jest.fn(() => true);
|
||||
const stderr = jest.fn(() => true);
|
||||
process.stdout.write = stdout as any;
|
||||
process.stderr.write = stderr as any;
|
||||
// Jest's console does not write through process.stdout, so record which stream was live.
|
||||
const printed: { text: string; toStdout: boolean }[] = [];
|
||||
jest.spyOn(console, 'log').mockImplementation((text: string) => {
|
||||
printed.push({ text, toStdout: process.stdout.write === stdout });
|
||||
});
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const { printJsonErrorOutput } = require('../json');
|
||||
printJsonErrorOutput({
|
||||
message: '\u001b[31mEAS project not configured.\u001b[39m',
|
||||
});
|
||||
printJsonErrorOutput({ message: 'update:republish command failed.' });
|
||||
});
|
||||
|
||||
expect(printed).toHaveLength(1);
|
||||
expect(printed[0].toStdout).toBe(true);
|
||||
expect(JSON.parse(printed[0].text)).toEqual({
|
||||
error: { message: 'EAS project not configured.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
@@ -23,30 +22,6 @@ export function printJsonOnlyOutput(value: object): void {
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonErrorOutput {
|
||||
message: string;
|
||||
code?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
let jsonErrorPrinted = false;
|
||||
|
||||
/**
|
||||
* Print `{ "error": { message, code?, requestId? } }` on stdout for a command that failed with --json.
|
||||
*
|
||||
* Once per process: `update:rollback` runs other commands in-process, and the innermost error is the one
|
||||
* that says what went wrong.
|
||||
*/
|
||||
export function printJsonErrorOutput(error: JsonErrorOutput): void {
|
||||
if (jsonErrorPrinted) {
|
||||
return;
|
||||
}
|
||||
jsonErrorPrinted = true;
|
||||
// The command may have failed before it could enable JSON output.
|
||||
enableJsonOutput();
|
||||
printJsonOnlyOutput({ error: { ...error, message: stripVTControlCharacters(error.message) } });
|
||||
}
|
||||
|
||||
function sanitizeValue(value: any): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(val => sanitizeValue(val));
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 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": "26.0.0",
|
||||
"version": "25.3.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": "26.0.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -17,7 +17,6 @@ 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/},
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user