mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e23aaa09 | ||
|
|
0a274a6fef | ||
|
|
78f6782970 | ||
|
|
5757091b33 | ||
|
|
dd6ad17179 | ||
|
|
695f567760 | ||
|
|
15e2302575 | ||
|
|
ee4f03d44e | ||
|
|
5e192ecba6 | ||
|
|
864b11effe | ||
|
|
62df1bae1c |
No files matched your search
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Run apsd setup in the background without delaying iOS app launch or preview readiness.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Fix simulator control action help routing and show help when a flag is incomplete.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Handle a device session stopped during startup as normal cleanup instead of reporting a startup error.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives.
|
||||
@@ -2,6 +2,55 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
|
||||
|
||||
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 27.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "26.0.0",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/downloader": "27.1.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "26.0.0",
|
||||
"@expo/template-file": "26.0.0",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
|
||||
const fakeError = new Error();
|
||||
const err = await resolveBuildPhaseErrorAsync(
|
||||
fakeError,
|
||||
[
|
||||
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
|
||||
'warn: This version of pnpm requires at least Node.js v22.13',
|
||||
'warn: The current version of Node.js is v20.19.4',
|
||||
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
|
||||
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
|
||||
],
|
||||
{
|
||||
job: { platform: Platform.ANDROID } as Job,
|
||||
phase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
env: {},
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
|
||||
expect(err.message).toBe(
|
||||
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
|
||||
);
|
||||
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
|
||||
});
|
||||
|
||||
it('detects xcode line error', async () => {
|
||||
vol.fromJSON({
|
||||
'/path/to/xcodelogs.log': originalFs.readFileSync(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ErrorHandler } from './errors.types';
|
||||
|
||||
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
|
||||
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
|
||||
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
|
||||
|
||||
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
|
||||
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
|
||||
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
|
||||
return new errors.UserError(
|
||||
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
|
||||
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
|
||||
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
|
||||
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
|
||||
);
|
||||
}
|
||||
|
||||
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
|
||||
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
|
||||
createError: createPnpmUnsupportedNodeVersionError,
|
||||
};
|
||||
|
||||
export function detectPnpmUnsupportedNodeVersionError(
|
||||
output: string
|
||||
): errors.UserError | undefined {
|
||||
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
|
||||
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
|
||||
|
||||
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
|
||||
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
|
||||
|
||||
import UserError = errors.UserError;
|
||||
|
||||
@@ -211,6 +212,7 @@ You are seeing this error because either:
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
pnpmUnsupportedNodeVersionErrorHandler,
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
phase: XCODE_BUILD_PHASE,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
import { isUsingModernYarnVersion } from '../utils/project';
|
||||
|
||||
@@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
|
||||
return getErrorOutput(err).includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
|
||||
return (
|
||||
line.includes(npmCacheUrl) &&
|
||||
@@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
class NpmCacheRegistryNonFatalError extends SystemError {
|
||||
override name = 'NpmCacheRegistryNonFatalError';
|
||||
constructor() {
|
||||
super('Non-fatal npm cache registry error during dependency install');
|
||||
}
|
||||
}
|
||||
|
||||
class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,12 @@ export async function setupAsync<TJob extends BuildJob>(
|
||||
}
|
||||
);
|
||||
|
||||
await setUpNpmrcAsync(ctx, ctx.logger);
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory: ctx.buildDirectory,
|
||||
workingDirectory: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
|
||||
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
|
||||
|
||||
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
|
||||
export type { GradleProfileTask } from './android/gradleProfile';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import turtleSpawnAsync from '@expo/turtle-spawn';
|
||||
import assert from 'node:assert';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'node:path';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
|
||||
@@ -17,17 +19,100 @@ import {
|
||||
} from '../repack';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('@expo/repack-app');
|
||||
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
|
||||
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
describe(createRepackBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
|
||||
const sandbox = args[args.indexOf('--prefix') + 1];
|
||||
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
|
||||
vol.fromJSON({
|
||||
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
|
||||
version: '2.0.0-next.1',
|
||||
}),
|
||||
});
|
||||
}) as unknown as typeof turtleSpawnAsync);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
|
||||
const globalCtx = createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: {},
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
|
||||
NPM_TOKEN: 'secret',
|
||||
});
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
repack_package: '@scope/fork',
|
||||
repack_version: 'next',
|
||||
},
|
||||
});
|
||||
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
|
||||
|
||||
await repackStep.executeAsync();
|
||||
|
||||
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
|
||||
const npmEnv = installCall[2]?.env;
|
||||
assert(npmEnv);
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
|
||||
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
|
||||
expect(installCall).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
npmEnv.HOME,
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--no-update-notifier',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@scope/fork@next',
|
||||
],
|
||||
{ env: npmEnv, logger: repackStep.ctx.logger },
|
||||
]);
|
||||
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
|
||||
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
|
||||
});
|
||||
|
||||
it('installs the repack package from the default registry when the job has no npm cache', async () => {
|
||||
const globalCtx = createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: {},
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
},
|
||||
});
|
||||
|
||||
await repackStep.executeAsync();
|
||||
|
||||
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
|
||||
expect(installCall[1]).not.toContain('--registry');
|
||||
});
|
||||
|
||||
it('should set generated output path for successful repack', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
|
||||
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
|
||||
import {
|
||||
createUploadToAscBuildFunction,
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
isRedundantBinaryUploadError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
@@ -45,6 +53,20 @@ describe(isInvalidBundleIdentifierError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isRedundantBinaryUploadError, () => {
|
||||
it('returns true when all errors are redundant-binary-upload codes', () => {
|
||||
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isRedundantBinaryUploadError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingPurposeStringError, () => {
|
||||
it('returns true when all errors are missing-purpose-string codes', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
|
||||
@@ -110,3 +132,62 @@ describe(parseMissingUsageDescriptionKeys, () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90189',
|
||||
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
|
||||
},
|
||||
],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
|
||||
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const projectTargetDirectory = '/workingdir/project';
|
||||
const homeDirectory = '/home/expo';
|
||||
|
||||
describe(createSetUpNpmrcBuildFunction, () => {
|
||||
beforeEach(async () => {
|
||||
vol.reset();
|
||||
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
|
||||
await vol.promises.mkdir(homeDirectory, { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
|
||||
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
|
||||
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
|
||||
NpmrcAuthTokenLine
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
version: resolvedRepackVersion,
|
||||
versionSpec: inputs.repack_version.value as string,
|
||||
pathEnv: env.PATH,
|
||||
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
@@ -226,16 +220,28 @@ function createOutputPath({
|
||||
*/
|
||||
async function installAndImportRepackAsync({
|
||||
packageName,
|
||||
version,
|
||||
versionSpec,
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger,
|
||||
}: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
versionSpec: string;
|
||||
pathEnv: string | undefined;
|
||||
npmCacheUrl: string | undefined;
|
||||
logger: bunyan;
|
||||
}): Promise<typeof import('@expo/repack-app')> {
|
||||
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
|
||||
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
|
||||
stdio: 'inherit',
|
||||
cwd: sandbox,
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec: `${packageName}@${versionSpec}`,
|
||||
directory: sandbox,
|
||||
npmCacheUrl,
|
||||
pathEnv,
|
||||
logger,
|
||||
});
|
||||
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
|
||||
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
|
||||
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
|
||||
return require(resolveFrom(sandbox, packageName));
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isRedundantBinaryUploadError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
|
||||
'App Store Connect requires unique build numbers within each app version (version train). ' +
|
||||
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
@@ -344,6 +357,10 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90189');
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { NpmrcTemplate } from '../../templates/npmrc';
|
||||
import { findPackagerRootDir } from '../../utils/packageManager';
|
||||
import { setUpNpmrcAsync } from '../../utils/npmrc';
|
||||
|
||||
export function createSetUpNpmrcBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
|
||||
name: 'Use NPM_TOKEN',
|
||||
__metricsId: 'eas/use_npm_token',
|
||||
fn: async (stepCtx, { env }) => {
|
||||
const { logger } = stepCtx;
|
||||
if (env.NPM_TOKEN) {
|
||||
logger.info('We detected that you set the NPM_TOKEN environment variable');
|
||||
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info('.npmrc already exists in your project directory, skipping generation');
|
||||
} else {
|
||||
logger.info('Creating .npmrc in your project directory with the following contents:');
|
||||
logger.info(NpmrcTemplate);
|
||||
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
|
||||
}
|
||||
} else {
|
||||
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info(
|
||||
`.npmrc found at ${path.relative(
|
||||
stepCtx.global.projectTargetDirectory,
|
||||
projectNpmrcPath
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -186,6 +186,7 @@ describe(runAgentAsync, () => {
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--no-update-notifier',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
@@ -13,6 +12,7 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age
|
||||
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
|
||||
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
|
||||
export type AgentKind = 'claude-code' | 'codex';
|
||||
@@ -76,17 +76,13 @@ export async function runAgentAsync({
|
||||
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
|
||||
logger.info(`Installing ${packageSpec}.`);
|
||||
try {
|
||||
await turtleSpawn(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
...['--prefix', cliDirectory],
|
||||
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
|
||||
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
|
||||
packageSpec,
|
||||
],
|
||||
{ env: minimalEnv, signal }
|
||||
);
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec,
|
||||
directory: cliDirectory,
|
||||
npmCacheUrl,
|
||||
pathEnv,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
|
||||
export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}';
|
||||
|
||||
export const NpmrcTemplate = `${NpmrcAuthTokenLine}
|
||||
registry=https://registry.npmjs.org/
|
||||
`;
|
||||
@@ -0,0 +1,89 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const npmCacheUrl = 'https://npm-cache.expo.test';
|
||||
|
||||
describe(installIsolatedNpmPackageAsync, () => {
|
||||
it('retries without the npm cache registry when the install fails against it', async () => {
|
||||
const logger = createMockLogger();
|
||||
const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), {
|
||||
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`,
|
||||
});
|
||||
spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never);
|
||||
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec: '@scope/fork@next',
|
||||
directory: '/tmp/sandbox',
|
||||
npmCacheUrl,
|
||||
pathEnv: '/usr/bin',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl]));
|
||||
expect(spawnMock.mock.calls[1][1]).not.toContain('--registry');
|
||||
expect(spawnMock.mock.calls[1][2]).toEqual({
|
||||
env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' },
|
||||
logger,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }),
|
||||
{ level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } }
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows an install failure that does not mention the npm cache registry', async () => {
|
||||
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
|
||||
stderr: 'npm error notarget No matching version found for @scope/fork@next.',
|
||||
});
|
||||
spawnMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
installIsolatedNpmPackageAsync({
|
||||
packageSpec: '@scope/fork@next',
|
||||
directory: '/tmp/sandbox',
|
||||
npmCacheUrl,
|
||||
pathEnv: '/usr/bin',
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows the cancellation of an install without retrying', async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error('cancelled');
|
||||
spawnMock.mockImplementationOnce((() => {
|
||||
controller.abort(cancellation);
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('npm install was killed with SIGTERM'), {
|
||||
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`,
|
||||
})
|
||||
);
|
||||
}) as unknown as typeof spawn);
|
||||
|
||||
await expect(
|
||||
installIsolatedNpmPackageAsync({
|
||||
packageSpec: '@scope/fork@next',
|
||||
directory: '/tmp/sandbox',
|
||||
npmCacheUrl,
|
||||
pathEnv: '/usr/bin',
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(cancellation);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc';
|
||||
import { setUpNpmrcAsync } from '../npmrc';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const projectTargetDirectory = '/workingdir/build';
|
||||
const homeDirectory = '/home/expo';
|
||||
|
||||
describe(setUpNpmrcAsync, () => {
|
||||
beforeEach(async () => {
|
||||
vol.reset();
|
||||
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
|
||||
await vol.promises.mkdir(homeDirectory, { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => {
|
||||
const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n';
|
||||
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents);
|
||||
await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false');
|
||||
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory: projectTargetDirectory,
|
||||
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe(
|
||||
`fund=false\n${NpmrcAuthTokenLine}\n`
|
||||
);
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
|
||||
projectContents
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => {
|
||||
await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never);
|
||||
const env = {
|
||||
NPM_TOKEN: 'npm_secret_token',
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
NPM_CONFIG_USERCONFIG: '~/npm/npmrc',
|
||||
};
|
||||
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory: projectTargetDirectory,
|
||||
env,
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['config', 'get', 'userconfig'],
|
||||
expect.objectContaining({ cwd: projectTargetDirectory, env })
|
||||
);
|
||||
expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe(
|
||||
`\n${NpmrcAuthTokenLine}\n`
|
||||
);
|
||||
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => {
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory: projectTargetDirectory,
|
||||
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
|
||||
NpmrcTemplate
|
||||
);
|
||||
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves an existing project .npmrc untouched on local builds', async () => {
|
||||
const existingContents = 'registry=https://registry.example.com/\n';
|
||||
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents);
|
||||
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory: projectTargetDirectory,
|
||||
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
|
||||
existingContents
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an existing .npmrc when NPM_TOKEN is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
await vol.promises.writeFile(
|
||||
`${projectTargetDirectory}/.npmrc`,
|
||||
'registry=https://registry.example.com/\n'
|
||||
);
|
||||
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory: projectTargetDirectory,
|
||||
env: { EAS_BUILD_RUNNER: 'eas-build' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc');
|
||||
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
resolvePackageExec,
|
||||
resolvePackageInstall,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../packageManager';
|
||||
|
||||
@@ -293,33 +290,6 @@ describe(resolvePackageInstall, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
it('should resolve version from package name and dist tag', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'latest',
|
||||
});
|
||||
expect(semver.valid(version)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return null if version cannot be resolved', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'nonexistent-dist-tag',
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(findPackagerRootDir, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
export async function installIsolatedNpmPackageAsync({
|
||||
packageSpec,
|
||||
directory,
|
||||
npmCacheUrl,
|
||||
pathEnv,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageSpec: string;
|
||||
directory: string;
|
||||
npmCacheUrl: string | undefined;
|
||||
pathEnv: string | undefined;
|
||||
logger?: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const installAsync = async (registryUrl: string | undefined): Promise<void> => {
|
||||
await spawn(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
directory,
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--no-update-notifier',
|
||||
...(registryUrl ? ['--registry', registryUrl] : []),
|
||||
packageSpec,
|
||||
],
|
||||
// npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME.
|
||||
{ env: { PATH: pathEnv, HOME: directory }, logger, signal }
|
||||
);
|
||||
};
|
||||
|
||||
if (!npmCacheUrl) {
|
||||
await installAsync(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await installAsync(npmCacheUrl);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (!isNpmCacheInstallFailure(error, npmCacheUrl)) {
|
||||
throw error;
|
||||
}
|
||||
logger?.warn(
|
||||
`Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
Sentry.capture(new NpmCacheRegistryInstallError(error), {
|
||||
level: 'warning',
|
||||
extras: { packageSpec, npmCacheUrl },
|
||||
});
|
||||
await installAsync(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
|
||||
export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
|
||||
return getErrorOutput(err).includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,56 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
import { BuildContext } from '../context';
|
||||
import { NpmrcTemplate } from '../templates/npmrc';
|
||||
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc';
|
||||
|
||||
export async function setUpNpmrcAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
|
||||
if (ctx.env.NPM_TOKEN) {
|
||||
await createNpmrcIfNotExistsAsync(ctx, logger);
|
||||
export async function setUpNpmrcAsync({
|
||||
projectTargetDirectory,
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
projectTargetDirectory: string;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (!env.NPM_TOKEN) {
|
||||
await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger);
|
||||
return;
|
||||
}
|
||||
logger.info('We detected that you set the NPM_TOKEN environment variable');
|
||||
// pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc
|
||||
if (env.EAS_BUILD_RUNNER === 'eas-build') {
|
||||
await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger);
|
||||
} else {
|
||||
await logIfNpmrcExistsAsync(ctx, logger);
|
||||
await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
|
||||
logger.info('We detected that you set the NPM_TOKEN environment variable');
|
||||
const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc');
|
||||
async function addNpmTokenToUserNpmrcAsync(
|
||||
workingDirectory: string,
|
||||
env: Record<string, string | undefined>,
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
const userNpmrcPath = (
|
||||
await spawn('npm', ['config', 'get', 'userconfig'], {
|
||||
cwd: findPackagerRootDir(workingDirectory),
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
).stdout.trim();
|
||||
logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`);
|
||||
await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`);
|
||||
}
|
||||
|
||||
async function createProjectNpmrcIfNotExistsAsync(
|
||||
projectTargetDirectory: string,
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info('.npmrc already exists in your project directory, skipping generation');
|
||||
} else {
|
||||
@@ -27,12 +60,13 @@ async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunya
|
||||
}
|
||||
}
|
||||
|
||||
async function logIfNpmrcExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
|
||||
const projectNpmrcPath = path.join(
|
||||
findPackagerRootDir(ctx.getReactNativeProjectDirectory()),
|
||||
'.npmrc'
|
||||
);
|
||||
async function logIfNpmrcExistsAsync(
|
||||
projectTargetDirectory: string,
|
||||
workingDirectory: string,
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`);
|
||||
logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
@@ -126,34 +125,6 @@ export function resolvePackageInstall(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of a package from the dist-tags.
|
||||
* Returns null if the version cannot be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName,
|
||||
distTag,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
packageName: string;
|
||||
distTag: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const distTags = JSON.parse(stdout);
|
||||
if (distTag in distTags) {
|
||||
return distTags[distTag];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findPackagerRootDir(currentDir: string): string {
|
||||
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "26.0.0"
|
||||
"@expo/steps": "27.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
|
||||
- Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
|
||||
- Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 27.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
|
||||
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
|
||||
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
|
||||
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
|
||||
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+216
-146
File diff suppressed because it is too large.
Load diff
Generated
+2217
-3
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/eas-json": "26.0.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/eas-json": "27.1.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "26.0.0",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -198,6 +198,28 @@ export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
|
||||
return getMockWorkflowRunsFragment();
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunFragment(
|
||||
status: WorkflowRunStatus,
|
||||
overrides?: Partial<WorkflowRunFragment>
|
||||
): WorkflowRunFragment {
|
||||
return {
|
||||
id: `run-${status}`,
|
||||
status,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
errors: [],
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunsFragment(
|
||||
params?:
|
||||
| undefined
|
||||
@@ -240,6 +262,7 @@ export function getMockWorkflowRunsFragment(
|
||||
status: WorkflowRunStatus.Failure,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
finalizedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
@@ -332,6 +355,7 @@ export function getMockWorkflowRunWithJobsFragment(
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
slug: 'app',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
@@ -364,6 +388,7 @@ export function getMockWorkflowRunWithBuildJobsFragment(
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
slug: 'app',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
getMockWorkflowRunFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import WorkflowRunCancel from '../../commands/workflow/cancel';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
|
||||
describe(WorkflowRunCancel, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('prompts with in-progress and queued runs and cancels the selected ones', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunCancel, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const inProgressRun = getMockWorkflowRunFragment(WorkflowRunStatus.InProgress);
|
||||
const waitingRun = getMockWorkflowRunFragment(WorkflowRunStatus.Waiting);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockImplementation(async (_graphqlClient, _appId, status) =>
|
||||
status === WorkflowRunStatus.InProgress ? [inProgressRun] : [waitingRun]
|
||||
);
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedRuns: [waitingRun.id] });
|
||||
|
||||
const cmd = mockTestCommand(WorkflowRunCancel, [], ctx);
|
||||
await cmd.run();
|
||||
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
WorkflowRunStatus.InProgress,
|
||||
50
|
||||
);
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
WorkflowRunStatus.Waiting,
|
||||
50
|
||||
);
|
||||
const promptCall = jest.mocked(promptAsync).mock.calls[0][0] as any;
|
||||
expect(promptCall.choices.map((choice: any) => choice.value)).toEqual([
|
||||
inProgressRun.id,
|
||||
waitingRun.id,
|
||||
]);
|
||||
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
|
||||
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
{ workflowRunId: waitingRun.id }
|
||||
);
|
||||
});
|
||||
|
||||
test('throws in non-interactive mode when no run IDs are given', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunCancel, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive'], ctx);
|
||||
await expect(cmd.run()).rejects.toThrow(
|
||||
'Must supply workflow run IDs as arguments when in non-interactive mode'
|
||||
);
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancels the given run IDs without prompting in non-interactive mode', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunCancel, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive', 'run-id-1'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).not.toHaveBeenCalled();
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
|
||||
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
{ workflowRunId: 'run-id-1' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,7 @@ describe(WorkflowRunList, () => {
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
finalizedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
|
||||
@@ -107,11 +107,7 @@ async function resolveEnvVarsAsync({
|
||||
return { ...serverEnvVars, ...buildProfile.env };
|
||||
} catch (e) {
|
||||
Log.error(`Failed to pull env variables for environment ${environment} from EAS servers`);
|
||||
Log.error(e);
|
||||
Log.error(
|
||||
'This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues'
|
||||
);
|
||||
return { ...buildProfile.env };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { collectReferencedWorkflowFilesAsync } from '../referencedFiles';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
const projectDir = '/app';
|
||||
const workflowsDir = `${projectDir}/.eas/workflows`;
|
||||
|
||||
function callerYaml(calls: Record<string, string>): string {
|
||||
return [
|
||||
'jobs:',
|
||||
...Object.entries(calls).flatMap(([jobKey, fileName]) => [
|
||||
` ${jobKey}:`,
|
||||
` uses: ./.eas/workflows/${fileName}`,
|
||||
]),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const calleeYaml = [
|
||||
'on:',
|
||||
' workflow_call: {}',
|
||||
'jobs:',
|
||||
' job:',
|
||||
' steps:',
|
||||
' - run: echo hi',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
async function collectAsync(
|
||||
rootYamlConfig: string
|
||||
): Promise<{ fileName: string; yamlConfig: string }[]> {
|
||||
return await collectReferencedWorkflowFilesAsync({
|
||||
projectDir,
|
||||
rootFileName: 'root.yml',
|
||||
rootYamlConfig,
|
||||
});
|
||||
}
|
||||
|
||||
describe(collectReferencedWorkflowFilesAsync, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('returns no files and reads nothing when the root has no uses jobs', async () => {
|
||||
vol.fromJSON({ [`${workflowsDir}/unused.yml`]: 'jobs: [not: valid' });
|
||||
|
||||
await expect(collectAsync(calleeYaml)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no files when the root is not valid YAML', async () => {
|
||||
await expect(collectAsync('jobs: [not: valid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('collects a file called from one job', async () => {
|
||||
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
|
||||
|
||||
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).resolves.toEqual([
|
||||
{ fileName: 'build.yml', yamlConfig: calleeYaml },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects nested calls in discovery order', async () => {
|
||||
const releaseYaml = `on:\n workflow_call: {}\n${callerYaml({ build: 'build.yml' })}`;
|
||||
vol.fromJSON({
|
||||
[`${workflowsDir}/release.yml`]: releaseYaml,
|
||||
[`${workflowsDir}/build.yaml`]: calleeYaml,
|
||||
[`${workflowsDir}/build.yml`]: calleeYaml,
|
||||
});
|
||||
|
||||
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).resolves.toEqual([
|
||||
{ fileName: 'release.yml', yamlConfig: releaseYaml },
|
||||
{ fileName: 'build.yml', yamlConfig: calleeYaml },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects each file once and stops at cycles', async () => {
|
||||
const aYaml = callerYaml({ b: 'b.yml' });
|
||||
const bYaml = callerYaml({ a: 'a.yml', root: 'root.yml', c: 'c.yml' });
|
||||
vol.fromJSON({
|
||||
[`${workflowsDir}/a.yml`]: aYaml,
|
||||
[`${workflowsDir}/b.yml`]: bYaml,
|
||||
[`${workflowsDir}/c.yml`]: calleeYaml,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectAsync(callerYaml({ first: 'a.yml', second: 'c.yml', third: 'a.yml' }))
|
||||
).resolves.toEqual([
|
||||
{ fileName: 'a.yml', yamlConfig: aYaml },
|
||||
{ fileName: 'c.yml', yamlConfig: calleeYaml },
|
||||
{ fileName: 'b.yml', yamlConfig: bYaml },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails when a called file does not exist', async () => {
|
||||
await expect(collectAsync(callerYaml({ ship: 'missing.yml' }))).rejects.toThrow(
|
||||
'Job "ship" in root.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when a nested called file does not exist', async () => {
|
||||
vol.fromJSON({ [`${workflowsDir}/release.yml`]: callerYaml({ build: 'missing.yml' }) });
|
||||
|
||||
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).rejects.toThrow(
|
||||
'Job "build" in release.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when a called file is not valid YAML', async () => {
|
||||
vol.fromJSON({ [`${workflowsDir}/build.yml`]: 'jobs: [not: valid' });
|
||||
|
||||
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).rejects.toThrow(
|
||||
/^Job "ship" in root\.yml uses \.\/\.eas\/workflows\/build\.yml, but \/app\/\.eas\/workflows\/build\.yml is not valid YAML: /
|
||||
);
|
||||
});
|
||||
|
||||
it('skips uses values the server does not accept', async () => {
|
||||
const rootYaml = [
|
||||
'jobs:',
|
||||
' nested:',
|
||||
' uses: ./.eas/workflows/nested/build.yml',
|
||||
' interpolated:',
|
||||
' uses: ./.eas/workflows/${{ inputs.name }}.yml',
|
||||
' notString:',
|
||||
' uses: { path: ./.eas/workflows/build.yml }',
|
||||
' valid:',
|
||||
' uses: ./.eas/workflows/build.yml',
|
||||
'',
|
||||
].join('\n');
|
||||
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
|
||||
|
||||
await expect(collectAsync(rootYaml)).resolves.toEqual([
|
||||
{ fileName: 'build.yml', yamlConfig: calleeYaml },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import { WorkflowJobStatus } from '../../../graphql/generated';
|
||||
import {
|
||||
getMockWorkflowRunFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
} from '../../../__tests__/commands/utils';
|
||||
import { WorkflowJobStatus, WorkflowRunStatus } from '../../../graphql/generated';
|
||||
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
|
||||
import { JobLogs, RawLogLine } from '../../logs/types';
|
||||
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
|
||||
import { formatActiveWorkflowRun, formatFailedWorkflowRun, processWorkflowRuns } from '../utils';
|
||||
|
||||
function jobWithLogs(
|
||||
logLines: RawLogLine[],
|
||||
@@ -135,3 +138,32 @@ describe(formatFailedWorkflowRun, () => {
|
||||
expect(output).toContain('line7');
|
||||
});
|
||||
});
|
||||
|
||||
describe(processWorkflowRuns, () => {
|
||||
test.each([WorkflowRunStatus.Success, WorkflowRunStatus.Failure, WorkflowRunStatus.Canceled])(
|
||||
'reports finalizedAt as finishedAt for final status %s',
|
||||
status => {
|
||||
const [run] = processWorkflowRuns([
|
||||
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
|
||||
]);
|
||||
expect(run.finishedAt).toBe('2022-01-01T12:00:00.000Z');
|
||||
}
|
||||
);
|
||||
|
||||
test('reports no finishedAt for a final run predating finalizedAt', () => {
|
||||
const [run] = processWorkflowRuns([getMockWorkflowRunFragment(WorkflowRunStatus.Success)]);
|
||||
expect(run.finishedAt).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
WorkflowRunStatus.New,
|
||||
WorkflowRunStatus.Waiting,
|
||||
WorkflowRunStatus.InProgress,
|
||||
WorkflowRunStatus.ActionRequired,
|
||||
])('reports no finishedAt for non-final status %s even with a lingering finalizedAt', status => {
|
||||
const [run] = processWorkflowRuns([
|
||||
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
|
||||
]);
|
||||
expect(run.finishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { parsedYamlFromWorkflowContents } from './parse';
|
||||
import { WorkflowReferencedFileInput } from '../../graphql/generated';
|
||||
|
||||
const WORKFLOW_CALL_USES_REGEX = /^\.\/\.eas\/workflows\/(?:(?!\$\{\{)[^/\\])+\.ya?ml$/;
|
||||
|
||||
type WorkflowCall = { callerFileName: string; callerJobKey: string; uses: string };
|
||||
|
||||
export async function collectReferencedWorkflowFilesAsync({
|
||||
projectDir,
|
||||
rootFileName,
|
||||
rootYamlConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
rootFileName: string;
|
||||
rootYamlConfig: string;
|
||||
}): Promise<WorkflowReferencedFileInput[]> {
|
||||
let rootYaml: unknown;
|
||||
try {
|
||||
rootYaml = parsedYamlFromWorkflowContents({ yamlConfig: rootYamlConfig });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collectedFileNames = new Set<string>([rootFileName]);
|
||||
const referencedFiles: WorkflowReferencedFileInput[] = [];
|
||||
const pendingCalls = getWorkflowCalls(rootFileName, rootYaml);
|
||||
for (const call of pendingCalls) {
|
||||
const fileName = path.posix.basename(call.uses);
|
||||
if (collectedFileNames.has(fileName)) {
|
||||
continue;
|
||||
}
|
||||
collectedFileNames.add(fileName);
|
||||
const { yamlConfig, parsedYaml } = await readReferencedWorkflowFileAsync(
|
||||
projectDir,
|
||||
fileName,
|
||||
call
|
||||
);
|
||||
referencedFiles.push({ fileName, yamlConfig });
|
||||
pendingCalls.push(...getWorkflowCalls(fileName, parsedYaml));
|
||||
}
|
||||
return referencedFiles;
|
||||
}
|
||||
|
||||
function getWorkflowCalls(callerFileName: string, parsedYaml: unknown): WorkflowCall[] {
|
||||
const jobs = (parsedYaml as { jobs?: unknown } | null)?.jobs;
|
||||
if (!jobs || typeof jobs !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(jobs).flatMap(([callerJobKey, job]) => {
|
||||
const uses = (job as { uses?: unknown } | null)?.uses;
|
||||
return typeof uses === 'string' && WORKFLOW_CALL_USES_REGEX.test(uses)
|
||||
? [{ callerFileName, callerJobKey, uses }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
async function readReferencedWorkflowFileAsync(
|
||||
projectDir: string,
|
||||
fileName: string,
|
||||
{ callerFileName, callerJobKey, uses }: WorkflowCall
|
||||
): Promise<{ yamlConfig: string; parsedYaml: unknown }> {
|
||||
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
|
||||
const describeCall = `Job "${callerJobKey}" in ${callerFileName} uses ${uses}, but ${filePath}`;
|
||||
|
||||
let yamlConfig: string;
|
||||
try {
|
||||
yamlConfig = await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(`${describeCall} does not exist.`);
|
||||
}
|
||||
throw new Error(`${describeCall} could not be read: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return { yamlConfig, parsedYaml: parsedYamlFromWorkflowContents({ yamlConfig }) };
|
||||
} catch (error) {
|
||||
throw new Error(`${describeCall} is not valid YAML: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type WorkflowRunResult = {
|
||||
triggerType: WorkflowTriggerType;
|
||||
trigger: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
finishedAt: string | null;
|
||||
workflowId: string;
|
||||
workflowName: string | null;
|
||||
workflowFileName: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
||||
|
||||
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
|
||||
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowRunByIdQuery,
|
||||
@@ -15,7 +16,7 @@ import { choicesFromJobLogs, stepLogTail } from '../logs/format';
|
||||
import { JobLogs } from '../logs/types';
|
||||
import { LogsWatcher } from '../logs/watcher';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora, updateSpinnerText } from '../../ora';
|
||||
import { Choice } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
@@ -81,7 +82,7 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
return runs.map(run => {
|
||||
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
|
||||
const finishedAt = isWorkflowRunFinal(run.status) ? (run.finalizedAt ?? null) : null;
|
||||
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
|
||||
return {
|
||||
id: run.id,
|
||||
@@ -135,6 +136,20 @@ function isJobCompleted(status: WorkflowJobStatus): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowRunFinal(status: WorkflowRunStatus): boolean {
|
||||
switch (status) {
|
||||
case WorkflowRunStatus.Success:
|
||||
case WorkflowRunStatus.Failure:
|
||||
case WorkflowRunStatus.Canceled:
|
||||
return true;
|
||||
case WorkflowRunStatus.New:
|
||||
case WorkflowRunStatus.Waiting:
|
||||
case WorkflowRunStatus.InProgress:
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
|
||||
|
||||
type JobWithLogs = {
|
||||
@@ -255,13 +270,12 @@ export async function showWorkflowStatusAsync(
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const waitingToStartPrefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
|
||||
const spinner = ora({
|
||||
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
|
||||
text: '',
|
||||
}).start();
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
|
||||
});
|
||||
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
|
||||
|
||||
const watcher = new LogsWatcher(
|
||||
() => createRealtimeLogsClient(graphqlClient),
|
||||
@@ -283,7 +297,27 @@ export async function showWorkflowStatusAsync(
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
// A queued run returns to NEW once its concurrency group frees up.
|
||||
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
|
||||
break;
|
||||
case WorkflowRunStatus.Waiting: {
|
||||
const {
|
||||
blockingWorkflowRun,
|
||||
workflow: { app },
|
||||
} = workflowRun;
|
||||
// The blocking run is in the same app: concurrency groups are app-scoped.
|
||||
const actionRequiredSuffix =
|
||||
blockingWorkflowRun?.status === WorkflowRunStatus.ActionRequired
|
||||
? ` — it needs your action first: ${link(getWorkflowRunUrl(app.ownerAccount.name, app.slug, blockingWorkflowRun.id))}`
|
||||
: '';
|
||||
const reason = blockingWorkflowRun
|
||||
? `Waiting for ${blockingWorkflowRun.name}${actionRequiredSuffix}`
|
||||
: 'Waiting for its concurrency group';
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.yellow Workflow run is queued:} ${reason}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
updateSpinnerText(spinner, {
|
||||
prefixText: chalk`{bold.green Workflow run is in progress:}`,
|
||||
|
||||
@@ -44,6 +44,10 @@ jest.mock('../../../ora', () => ({
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual<typeof import('fs-extra')>('fs-extra');
|
||||
return { ...actual, stat: jest.fn(actual.stat) };
|
||||
});
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
@@ -236,12 +240,23 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('explains local and explicit host upload paths without running an action', async () => {
|
||||
await createCommand(['media.add', '--help', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
' File paths are local. Use host:/absolute/path for files on the simulator host.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prints the host action schema as JSON for help', async () => {
|
||||
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
action: 'app.install',
|
||||
...catalog['app.install'],
|
||||
description: expect.stringContaining('host:/absolute/path'),
|
||||
});
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -328,6 +343,39 @@ describe(SimulatorControl, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects host paths for ID-only uploads and does not advertise them', async () => {
|
||||
served = {
|
||||
...catalog,
|
||||
'app.file.upload': {
|
||||
type: 'object',
|
||||
properties: { uploadId: { type: 'string' } },
|
||||
upload: 'uploadId',
|
||||
uploadAs: 'id',
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
createCommand(['app.file.upload', 'host:/tmp/a.json', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('requires a local file');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
await createCommand(['app.file.upload', '--help', '--id', 'session-123']).runAsync();
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.join('\n')).not.toContain('host:/absolute/path');
|
||||
expect(lines.join('\n')).toContain('requires a local file');
|
||||
await createCommand(['app.file.upload', '--help', '--json', '--id', 'session-123']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining('requires a local file'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('explains how to upload a local filename beginning with host:', async () => {
|
||||
await expect(
|
||||
createCommand(['media.add', 'host:photo.png', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('./host:photo.png');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an upload that the action goes on reading', async () => {
|
||||
const localFile = path.join(tmpDir, 'feed.mp4');
|
||||
await fs.writeFile(localFile, 'video bytes');
|
||||
@@ -395,17 +443,65 @@ describe(SimulatorControl, () => {
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a path that is not a local file through unchanged', async () => {
|
||||
actionResult = ok('');
|
||||
it('refuses a missing absolute local file without running an action', async () => {
|
||||
const missing = path.join(tmpDir, 'missing.png');
|
||||
await expect(
|
||||
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('does not exist on this computer');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
await createCommand([
|
||||
'app.install',
|
||||
'/Users/runner/MyApp.ipa',
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
it('preserves a local filesystem failure without sending an action', async () => {
|
||||
const denied = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
||||
const stat = jest
|
||||
.mocked<(path: string) => Promise<fs.Stats>>(fs.stat)
|
||||
.mockRejectedValueOnce(denied);
|
||||
try {
|
||||
await expect(
|
||||
createCommand([
|
||||
'media.add',
|
||||
path.join(tmpDir, 'private.png'),
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync()
|
||||
).rejects.toBe(denied);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
stat
|
||||
.mockReset()
|
||||
.mockImplementation(jest.requireActual<typeof import('fs-extra')>('fs-extra').stat);
|
||||
}
|
||||
});
|
||||
|
||||
expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
|
||||
it('passes an explicit host path through without uploading a matching local file', async () => {
|
||||
const localFile = path.join(tmpDir, 'existing.png');
|
||||
await fs.writeFile(localFile, 'local bytes');
|
||||
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
|
||||
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
|
||||
});
|
||||
|
||||
it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
|
||||
await expect(
|
||||
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('absolute');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('uploads a local filename beginning with host: when explicitly relative', async () => {
|
||||
const previous = process.cwd();
|
||||
await fs.writeFile(path.join(tmpDir, 'host:photo.png'), 'png bytes');
|
||||
try {
|
||||
process.chdir(tmpDir);
|
||||
await createCommand(['media.add', './host:photo.png', '--id', 'session-123']).runAsync();
|
||||
expect(actionRequests().map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'media.add',
|
||||
'upload.remove',
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(previous);
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
@@ -443,7 +539,14 @@ describe(SimulatorControl, () => {
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'app.install': expect.objectContaining({
|
||||
description: expect.stringContaining('host:/absolute/path'),
|
||||
}),
|
||||
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
|
||||
})
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { downloadSimulatorArtifactAsync } from '../../../simulator/artifacts';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
fetchSimulatorPreviewJsonAsync,
|
||||
resolveSimulatorPreviewAsync,
|
||||
streamSimulatorPreviewAsync,
|
||||
} from '../../../simulator/preview';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorLogs from '../logs';
|
||||
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../simulator/artifacts', () => ({
|
||||
...jest.requireActual('../../../simulator/artifacts'),
|
||||
downloadSimulatorArtifactAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/preview', () => ({
|
||||
...jest.requireActual('../../../simulator/preview'),
|
||||
resolveSimulatorPreviewAsync: jest.fn(),
|
||||
fetchSimulatorPreviewJsonAsync: jest.fn(),
|
||||
streamSimulatorPreviewAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResolvePreviewAsync = jest.mocked(resolveSimulatorPreviewAsync);
|
||||
const mockFetchJsonAsync = jest.mocked(fetchSimulatorPreviewJsonAsync);
|
||||
const mockStreamAsync = jest.mocked(streamSimulatorPreviewAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
const mockLog = jest.mocked(Log.log);
|
||||
const mockWarn = jest.mocked(Log.warn);
|
||||
const mockWarnToStderr = jest.mocked(Log.warnToStderr);
|
||||
const originalColorLevel = chalk.level;
|
||||
const preview = {
|
||||
deviceRunSessionId: 'session-id',
|
||||
baseUrl: new URL('https://preview.test'),
|
||||
token: 'preview-token',
|
||||
};
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/test/project';
|
||||
const line = {
|
||||
seq: 7,
|
||||
at: Date.parse('2026-10-05T12:00:00.000Z'),
|
||||
raw: JSON.stringify({
|
||||
timestamp: '2026-10-05T12:00:00.000Z',
|
||||
processImagePath: '/apps/CoinFlip.app/CoinFlip',
|
||||
processID: 42,
|
||||
eventMessage: 'Initialization request completed.',
|
||||
}),
|
||||
};
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorLogs, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.EAS_SIMULATOR_SESSION_ID = 'session-id';
|
||||
chalk.level = 0;
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
jest
|
||||
.mocked(DeviceRunSessionQuery.byIdAsync)
|
||||
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.InProgress } as Awaited<
|
||||
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
|
||||
>);
|
||||
mockResolvePreviewAsync.mockResolvedValue(preview);
|
||||
mockFetchJsonAsync.mockResolvedValue(createSnapshot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EAS_SIMULATOR_SESSION_ID;
|
||||
chalk.level = originalColorLevel;
|
||||
});
|
||||
|
||||
it.each(['Error', 16, 'Fault', 17, 'Debug', 2])(
|
||||
'styles OS severity %s after removing remote terminal controls',
|
||||
async messageType => {
|
||||
chalk.level = 1;
|
||||
mockFetchJsonAsync.mockResolvedValue(
|
||||
createSnapshot([
|
||||
{
|
||||
...line,
|
||||
raw: JSON.stringify({
|
||||
...JSON.parse(line.raw),
|
||||
messageType,
|
||||
processImagePath: '/apps/\u001b[35mCoinFlip\u001b[0m',
|
||||
eventMessage: '\u001b[35mInitialization request completed.\u001b[0m\u0007',
|
||||
}),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await createCommand(['--timestamp']).runAsync();
|
||||
|
||||
const output = mockLog.mock.calls[0][0];
|
||||
expect(output).toContain('\u001b[36m[CoinFlip:42]\u001b[39m');
|
||||
expect(output).not.toContain('\u001b[35m');
|
||||
expect(output).not.toContain('\u0007');
|
||||
const message = 'Initialization request completed.';
|
||||
const styledMessage =
|
||||
messageType === 'Debug' || messageType === 2
|
||||
? chalk.dim(message)
|
||||
: messageType === 'Fault' || messageType === 17
|
||||
? chalk.red.bold(message)
|
||||
: chalk.red(message);
|
||||
expect(output).toContain(styledMessage);
|
||||
}
|
||||
);
|
||||
|
||||
it('prints the full JSON snapshot for an explicit session without starting the log source', async () => {
|
||||
const snapshot = createSnapshot([line]);
|
||||
mockFetchJsonAsync.mockResolvedValue(snapshot);
|
||||
const command = createCommand(['--id', 'session-id', '--json']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-id');
|
||||
expect(mockResolvePreviewAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'session-id' })
|
||||
);
|
||||
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
|
||||
query: { scope: 'user-apps', limit: '100', snapshot: '1' },
|
||||
});
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
deviceRunSessionId: 'session-id',
|
||||
...snapshot,
|
||||
});
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWarn).not.toHaveBeenCalled();
|
||||
expect(mockStreamAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dotenv session ID when no ID is provided', async () => {
|
||||
mockLoadSimulatorEnvAsync.mockImplementation(async () => {
|
||||
process.env.EAS_SIMULATOR_SESSION_ID = 'dotenv-id';
|
||||
});
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-id');
|
||||
});
|
||||
|
||||
it('formats app messages and forwards scope and buffer limit', async () => {
|
||||
mockFetchJsonAsync.mockResolvedValue(createSnapshot([line]));
|
||||
|
||||
await createCommand(['--scope', 'all', '--limit', '3']).runAsync();
|
||||
|
||||
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
|
||||
query: { scope: 'all', limit: '3', snapshot: '1' },
|
||||
});
|
||||
expect(mockLog).toHaveBeenCalledWith('[CoinFlip:42] Initialization request completed.');
|
||||
});
|
||||
|
||||
it('shows original timestamps for parsed and unparsed snapshot lines when requested', async () => {
|
||||
mockFetchJsonAsync.mockResolvedValue(
|
||||
createSnapshot([line, { ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }])
|
||||
);
|
||||
|
||||
await createCommand(['--timestamp']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
|
||||
);
|
||||
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:00.000Z raw message');
|
||||
});
|
||||
|
||||
it('prints unparsed lines safely and reports an unhealthy log source', async () => {
|
||||
mockFetchJsonAsync.mockResolvedValue({
|
||||
...createSnapshot([{ ...line, raw: '\u001b[31mraw message\u001b[0m\u0007' }]),
|
||||
streamError: '\u001b[31mlog source restarting\u001b[0m\u0007',
|
||||
});
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith('raw message');
|
||||
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
|
||||
});
|
||||
|
||||
it('reports an unhealthy log source without claiming the buffer is simply empty', async () => {
|
||||
mockFetchJsonAsync.mockResolvedValue({
|
||||
...createSnapshot(),
|
||||
streamError: 'log source restarting',
|
||||
});
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockWarn).toHaveBeenCalledWith('log source restarting');
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the log source error in JSON output instead of warning', async () => {
|
||||
const snapshot = { ...createSnapshot(), streamError: 'log source restarting' };
|
||||
mockFetchJsonAsync.mockResolvedValue(snapshot);
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
deviceRunSessionId: 'session-id',
|
||||
...snapshot,
|
||||
});
|
||||
expect(mockWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the current time for a snapshot line with an invalid time', async () => {
|
||||
const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-05T13:00:00.000Z'));
|
||||
mockFetchJsonAsync.mockResolvedValue(
|
||||
createSnapshot([line, { ...line, at: 1e300, raw: 'raw message' }])
|
||||
);
|
||||
|
||||
await createCommand(['--timestamp']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T13:00:00.000Z raw message');
|
||||
now.mockRestore();
|
||||
});
|
||||
|
||||
it('explains that an empty snapshot needs following before actions', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'No buffered logs. Use --follow to collect logs before performing actions.'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats replayed and new raw SSE logs without requiring envelopes', async () => {
|
||||
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
|
||||
onData(line.raw);
|
||||
onData('\u001b[31mnew message\u001b[0m\u0007');
|
||||
});
|
||||
|
||||
await createCommand(['--id', 'session-id', '--follow']).runAsync();
|
||||
|
||||
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
|
||||
query: { scope: 'user-apps', limit: '100' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockLog).toHaveBeenCalledTimes(2);
|
||||
expect(mockLog).toHaveBeenNthCalledWith(1, '[CoinFlip:42] Initialization request completed.');
|
||||
expect(mockLog).toHaveBeenLastCalledWith('new message');
|
||||
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses server envelopes for accurate timestamps while following unparsed logs', async () => {
|
||||
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
|
||||
onData(JSON.stringify(line));
|
||||
onData(JSON.stringify({ ...line, at: line.at + 1_000, raw: 'new message' }));
|
||||
});
|
||||
|
||||
await createCommand(['--follow', '--timestamp']).runAsync();
|
||||
|
||||
expect(mockStreamAsync).toHaveBeenCalledWith(preview, '/logs', expect.any(Function), {
|
||||
query: { scope: 'user-apps', limit: '100', envelope: '1' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockLog).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
|
||||
);
|
||||
expect(mockLog).toHaveBeenLastCalledWith('2026-10-05T12:00:01.000Z new message');
|
||||
});
|
||||
|
||||
it('prints a frame that is not an envelope instead of ending the stream', async () => {
|
||||
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
|
||||
onData('\u001b[31mnot an envelope\u001b[0m');
|
||||
onData(JSON.stringify({ seq: 8 }));
|
||||
onData(JSON.stringify(line));
|
||||
});
|
||||
|
||||
await createCommand(['--follow', '--timestamp']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledTimes(3);
|
||||
expect(mockLog).toHaveBeenNthCalledWith(1, 'not an envelope');
|
||||
expect(mockLog).toHaveBeenNthCalledWith(2, '{"seq":8}');
|
||||
expect(mockLog).toHaveBeenLastCalledWith(
|
||||
'2026-10-05T12:00:00.000Z [CoinFlip:42] Initialization request completed.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
JSON.stringify({ ...line, at: 'not-a-number' }),
|
||||
JSON.stringify({ ...line, at: null }),
|
||||
JSON.stringify(line).replace(String(line.at), '1e400'),
|
||||
JSON.stringify({ ...line, at: 1e300 }),
|
||||
])('prints an envelope with an invalid time instead of ending the stream %#', async frame => {
|
||||
mockStreamAsync.mockImplementation(async (_preview, _path, onData) => {
|
||||
onData(frame);
|
||||
});
|
||||
|
||||
await createCommand(['--follow', '--timestamp']).runAsync();
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(frame);
|
||||
});
|
||||
|
||||
it('reports a failed log source while its stream only sends heartbeats', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
mockFetchJsonAsync
|
||||
.mockResolvedValueOnce({
|
||||
...createSnapshot(),
|
||||
status: 'restarting',
|
||||
streamError: '\u001b[31mlog stream exited (code 1, signal null)\u001b[0m',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...createSnapshot(),
|
||||
status: 'restarting',
|
||||
streamError: 'log stream exited (code 1, signal null)',
|
||||
})
|
||||
.mockResolvedValueOnce(createSnapshot());
|
||||
// A connected stream that only receives heartbeats never calls onData.
|
||||
mockStreamAsync.mockImplementation(async () => {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
|
||||
await createCommand(['--follow', '--scope', 'all']).runAsync();
|
||||
|
||||
expect(mockFetchJsonAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockFetchJsonAsync).toHaveBeenCalledWith(preview, '/logs', {
|
||||
query: { scope: 'all', limit: '1', snapshot: '1' },
|
||||
});
|
||||
// Both notices go to stderr, so `--follow > logs.txt` keeps only log lines.
|
||||
expect(mockWarnToStderr.mock.calls).toEqual([
|
||||
[
|
||||
"Simulator logs are not being collected. The session's log source stopped: log stream exited (code 1, signal null). It restarts automatically, and logs from this gap are missing.",
|
||||
],
|
||||
['Simulator log collection resumed.'],
|
||||
]);
|
||||
expect(mockWarn).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops checking the log source when following stops', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
mockStreamAsync.mockResolvedValue();
|
||||
|
||||
await createCommand(['--follow']).runAsync();
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops following on Ctrl+C and removes its interrupt handler', async () => {
|
||||
const listeners = process.listeners('SIGINT');
|
||||
let signal: AbortSignal | undefined;
|
||||
mockStreamAsync.mockImplementation(async (_preview, _path, _onData, options) => {
|
||||
signal = options?.signal;
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
|
||||
await createCommand(['--follow']).runAsync();
|
||||
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('rejects JSON follow output before resolving a session', async () => {
|
||||
await expect(createCommand(['--json', '--follow']).runAsync()).rejects.toThrow(
|
||||
'--json=true cannot also be provided when using --follow'
|
||||
);
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockLoadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
|
||||
'downloads logs for a %s session without accessing preview',
|
||||
async status => {
|
||||
const session = { id: 'session-id', status } as Awaited<
|
||||
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
|
||||
>;
|
||||
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue(session);
|
||||
await createCommand(['--artifact', '2', '--output', 'logs.ndjson', '--json']).runAsync();
|
||||
expect(downloadSimulatorArtifactAsync).toHaveBeenCalledWith(session, 'simulator-log', {
|
||||
artifact: 2,
|
||||
output: 'logs.ndjson',
|
||||
nonInteractive: true,
|
||||
json: true,
|
||||
});
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
expect(DeviceRunSessionQuery.byIdAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects following a stopped session with a download action', async () => {
|
||||
jest
|
||||
.mocked(DeviceRunSessionQuery.byIdAsync)
|
||||
.mockResolvedValue({ id: 'session-id', status: DeviceRunSessionStatus.Stopped } as Awaited<
|
||||
ReturnType<typeof DeviceRunSessionQuery.byIdAsync>
|
||||
>);
|
||||
await expect(createCommand(['--follow']).runAsync()).rejects.toThrow('Use --output <path>');
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--artifact', '1'],
|
||||
['--output', 'logs.ndjson'],
|
||||
])('rejects artifact flags on a running session %s', async (...args) => {
|
||||
await expect(createCommand(args).runAsync()).rejects.toThrow(
|
||||
'only available for stopped sessions'
|
||||
);
|
||||
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([[], ['--follow'], ['--json']])(
|
||||
'explains that a live Android session has no logs %s',
|
||||
async (...args) => {
|
||||
jest.mocked(DeviceRunSessionQuery.byIdAsync).mockResolvedValue({
|
||||
id: 'session-id',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Android,
|
||||
} as Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>);
|
||||
|
||||
await expect(createCommand(args).runAsync()).rejects.toThrow(
|
||||
'Live logs are only available for iOS simulator sessions.'
|
||||
);
|
||||
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
|
||||
expect(mockStreamAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['0', '-1'])('rejects a nonpositive buffer limit %s', async limit => {
|
||||
await expect(createCommand(['--limit', limit]).runAsync()).rejects.toThrow();
|
||||
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid scopes', async () => {
|
||||
await expect(createCommand(['--scope', 'system']).runAsync()).rejects.toThrow();
|
||||
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops before contacting the preview when there is no session', async () => {
|
||||
delete process.env.EAS_SIMULATOR_SESSION_ID;
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('No simulator session ID provided.');
|
||||
|
||||
expect(mockResolvePreviewAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchJsonAsync).not.toHaveBeenCalled();
|
||||
expect(mockStreamAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates preview errors without printing an empty result', async () => {
|
||||
mockFetchJsonAsync.mockRejectedValue(new Error('The simulator preview refused access.'));
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(
|
||||
'The simulator preview refused access.'
|
||||
);
|
||||
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(args: string[]): SimulatorLogs {
|
||||
const command = new SimulatorLogs(args, getMockOclifConfig());
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function createSnapshot(lines: (typeof line)[] = []): object {
|
||||
return {
|
||||
device: 'device-id',
|
||||
latestSeq: 7,
|
||||
oldestSeq: 1,
|
||||
bufferedBytes: 200,
|
||||
status: 'streaming',
|
||||
streamError: null,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
@@ -148,7 +148,11 @@ export default class SimulatorControl extends EasCommand {
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(catalog);
|
||||
printJsonOnlyOutput(
|
||||
Object.fromEntries(
|
||||
Object.entries(catalog).map(([name, entry]) => [name, withUploadPathHelp(entry)])
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const names = Object.keys(catalog).sort();
|
||||
@@ -167,7 +171,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
|
||||
private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ ...entry, action });
|
||||
printJsonOnlyOutput({ ...withUploadPathHelp(entry), action });
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
@@ -198,6 +202,11 @@ export default class SimulatorControl extends EasCommand {
|
||||
' Action arguments refer to the simulator host. Use --output for the local filename.'
|
||||
);
|
||||
}
|
||||
const uploadHelp = uploadPathHelp(entry);
|
||||
if (uploadHelp) {
|
||||
Log.newLine();
|
||||
Log.log(` ${uploadHelp}`);
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
@@ -270,8 +279,24 @@ export default class SimulatorControl extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function uploadPathHelp(entry: HostActionCatalogEntry): string | undefined {
|
||||
if (!entry.upload) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.uploadAs === 'id'
|
||||
? 'This action requires a local file to upload. host: paths are not supported.'
|
||||
: 'File paths are local. Use host:/absolute/path for files on the simulator host.';
|
||||
}
|
||||
|
||||
function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEntry {
|
||||
const help = uploadPathHelp(entry);
|
||||
return help
|
||||
? { ...entry, description: [entry.description, help].filter(Boolean).join('\n\n') }
|
||||
: entry;
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
|
||||
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
@@ -279,13 +304,33 @@ async function findLocalUploadAsync(
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch(() => null);
|
||||
if (value.startsWith('host:')) {
|
||||
if (uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat && !path.isAbsolute(value)) {
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { downloadSimulatorArtifactAsync, hasSimulatorArtifacts } from '../../simulator/artifacts';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
type SimulatorPreview,
|
||||
fetchSimulatorPreviewJsonAsync,
|
||||
resolveSimulatorPreviewAsync,
|
||||
streamSimulatorPreviewAsync,
|
||||
} from '../../simulator/preview';
|
||||
import {
|
||||
formatLogLine,
|
||||
isValidLogTime,
|
||||
stripTerminalControlCharacters,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// serve-sim keeps sending heartbeats while its log source restarts, so the stream alone cannot show a failure.
|
||||
const LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS = 10_000;
|
||||
|
||||
interface SimulatorLogLine {
|
||||
seq: number;
|
||||
at: number;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface SimulatorLogsSnapshot {
|
||||
device: string;
|
||||
latestSeq: number;
|
||||
oldestSeq: number;
|
||||
bufferedBytes: number;
|
||||
status: string;
|
||||
streamError: string | null;
|
||||
lines: SimulatorLogLine[];
|
||||
}
|
||||
|
||||
export default class SimulatorLogs extends EasCommand {
|
||||
static override aliases = ['sim:logs'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show buffered logs from a remote iOS simulator session';
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> simulator:logs --follow',
|
||||
'<%= config.bin %> simulator:logs --json',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
artifact: Flags.integer({
|
||||
min: 1,
|
||||
description: 'Artifact index to download from a stopped session (1-based).',
|
||||
}),
|
||||
output: Flags.string({
|
||||
char: 'o',
|
||||
description: 'Save a stopped session artifact to this path.',
|
||||
}),
|
||||
follow: Flags.boolean({
|
||||
char: 'f',
|
||||
description: 'Stream new logs. Start following before performing actions to collect them.',
|
||||
exclusive: ['json'],
|
||||
}),
|
||||
scope: Flags.option({
|
||||
description: 'Show user app logs or all device logs while the session runs.',
|
||||
options: ['user-apps', 'all'] as const,
|
||||
default: 'user-apps',
|
||||
})(),
|
||||
limit: Flags.integer({
|
||||
description: 'Maximum number of buffered log lines to show while the session runs.',
|
||||
default: 100,
|
||||
min: 1,
|
||||
}),
|
||||
timestamp: Flags.boolean({
|
||||
description: 'Show timestamps in live human-readable log output.',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
// Before parsing, so only the JSON result reaches stdout.
|
||||
if (this.argv.includes('--json')) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const { flags } = await this.parse(SimulatorLogs);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorLogs, { nonInteractive });
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const deviceRunSessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!deviceRunSessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
if (hasSimulatorArtifacts(session)) {
|
||||
if (flags.follow) {
|
||||
throw new Error(
|
||||
'The session has stopped. --follow is only available while it runs. Use --output <path> to download its logs artifact.'
|
||||
);
|
||||
}
|
||||
await downloadSimulatorArtifactAsync(session, 'simulator-log', {
|
||||
artifact: flags.artifact,
|
||||
output: flags.output,
|
||||
nonInteractive,
|
||||
json: jsonFlag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session.platform === AppPlatform.Android) {
|
||||
throw new Error(
|
||||
'Live logs are only available for iOS simulator sessions. This session runs an Android emulator, whose preview server does not stream logs. Start an iOS session with `eas simulator:start --platform ios` to read logs.'
|
||||
);
|
||||
}
|
||||
const preview = await resolveSimulatorPreviewAsync(session);
|
||||
if (flags.artifact !== undefined || flags.output !== undefined) {
|
||||
throw new Error(
|
||||
'--artifact and --output are only available for stopped sessions. To save live logs, follow them into a file: `eas simulator:logs --follow > logs.txt`.'
|
||||
);
|
||||
}
|
||||
const query = { scope: flags.scope, limit: String(flags.limit) };
|
||||
|
||||
if (flags.follow) {
|
||||
const abortController = new AbortController();
|
||||
const interruptHandler = (): void => {
|
||||
if (abortController.signal.aborted) {
|
||||
process.exit(130);
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
process.on('SIGINT', interruptHandler);
|
||||
const stopHealthChecks = watchLogSourceHealth(preview, flags.scope, abortController.signal);
|
||||
try {
|
||||
await streamSimulatorPreviewAsync(
|
||||
preview,
|
||||
'/logs',
|
||||
data => {
|
||||
Log.log(flags.timestamp ? formatLogEnvelope(data) : formatLogLine(data));
|
||||
},
|
||||
{
|
||||
query: { ...query, ...(flags.timestamp ? { envelope: '1' } : {}) },
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
stopHealthChecks();
|
||||
process.removeListener('SIGINT', interruptHandler);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(preview, '/logs', {
|
||||
query: { ...query, snapshot: '1' },
|
||||
});
|
||||
if (!jsonFlag && snapshot.streamError) {
|
||||
Log.warn(stripTerminalControlCharacters(snapshot.streamError));
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ deviceRunSessionId: preview.deviceRunSessionId, ...snapshot });
|
||||
return;
|
||||
}
|
||||
if (snapshot.lines.length === 0) {
|
||||
// The warning above already explains why the buffer can be empty.
|
||||
if (!snapshot.streamError) {
|
||||
Log.log('No buffered logs. Use --follow to collect logs before performing actions.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const line of snapshot.lines) {
|
||||
Log.log(formatLogLine(line.raw, flags.timestamp, line.at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogEnvelope(data: string): string {
|
||||
let line: Partial<SimulatorLogLine> | undefined;
|
||||
try {
|
||||
line = JSON.parse(data);
|
||||
} catch {}
|
||||
return typeof line?.raw === 'string' && isValidLogTime(line.at)
|
||||
? formatLogLine(line.raw, true, line.at)
|
||||
: formatLogLine(data);
|
||||
}
|
||||
|
||||
/** Polls the log source while following and reports when it stops or recovers. Returns a stop function. */
|
||||
function watchLogSourceHealth(
|
||||
preview: SimulatorPreview,
|
||||
scope: string,
|
||||
signal: AbortSignal
|
||||
): () => void {
|
||||
let failing = false;
|
||||
let checking = false;
|
||||
const checkAsync = async (): Promise<void> => {
|
||||
checking = true;
|
||||
try {
|
||||
const { status, streamError } = await fetchSimulatorPreviewJsonAsync<SimulatorLogsSnapshot>(
|
||||
preview,
|
||||
'/logs',
|
||||
{ query: { scope, limit: '1', snapshot: '1' } }
|
||||
);
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (!failing && status !== 'streaming') {
|
||||
failing = true;
|
||||
const reason = streamError ? `: ${stripTerminalControlCharacters(streamError)}` : '';
|
||||
// stderr, so the notice stays out of `--follow > logs.txt`.
|
||||
Log.warnToStderr(
|
||||
`Simulator logs are not being collected. The session's log source stopped${reason}. It restarts automatically, and logs from this gap are missing.`
|
||||
);
|
||||
} else if (failing && status === 'streaming' && !streamError) {
|
||||
failing = false;
|
||||
Log.warnToStderr('Simulator log collection resumed.');
|
||||
}
|
||||
} catch {
|
||||
// The stream itself reports connection failures.
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => {
|
||||
if (!checking) {
|
||||
void checkAsync();
|
||||
}
|
||||
}, LOG_SOURCE_HEALTH_CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { collectReferencedWorkflowFilesAsync } from '../../../commandUtils/workflow/referencedFiles';
|
||||
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
|
||||
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { WorkflowFile } from '../../../utils/workflowFile';
|
||||
import WorkflowRun from '../run';
|
||||
|
||||
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../commandUtils/workflow/referencedFiles');
|
||||
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
|
||||
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../project/uploadAccountScopedFileAsync');
|
||||
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
|
||||
jest.mock('../../../utils/workflowFile');
|
||||
jest.mock('../../../commandUtils/workflow/utils', () => ({
|
||||
...jest.requireActual('../../../commandUtils/workflow/utils'),
|
||||
fileExistsAsync: jest.fn(),
|
||||
maybeReadStdinAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
}));
|
||||
|
||||
const mockSpawn = jest.mocked(spawnAsync);
|
||||
const mockCollectReferencedFiles = jest.mocked(collectReferencedWorkflowFilesAsync);
|
||||
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
|
||||
const mockGetOrCreateRevision = jest.mocked(
|
||||
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
|
||||
);
|
||||
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
|
||||
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
|
||||
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
|
||||
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
|
||||
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
|
||||
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
|
||||
const mockFileExists = jest.mocked(fileExistsAsync);
|
||||
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
|
||||
|
||||
const rootYamlConfig = 'jobs:\n ship:\n uses: ./.eas/workflows/build.yml\n';
|
||||
const referencedFiles = [{ fileName: 'build.yml', yamlConfig: 'on:\n workflow_call: {}\n' }];
|
||||
|
||||
describe('WorkflowRun referenced workflow files', () => {
|
||||
let mockConfig: Config;
|
||||
let exitSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockConfig = new Config({ root: __dirname });
|
||||
mockConfig.runHook = async () => ({ failures: [], successes: [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
mockReadStdin.mockResolvedValue(null);
|
||||
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
|
||||
mockValidate.mockResolvedValue(undefined as never);
|
||||
mockReadWorkflow.mockResolvedValue({
|
||||
filePath: '/test/project/.eas/workflows/test.yml',
|
||||
yamlConfig: rootYamlConfig,
|
||||
} as never);
|
||||
mockCollectReferencedFiles.mockResolvedValue(referencedFiles);
|
||||
mockFileExists.mockResolvedValue(false);
|
||||
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
|
||||
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
|
||||
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
|
||||
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
|
||||
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
|
||||
mockGetOrCreateRevision.mockResolvedValue({ id: 'rev-1', yamlConfig: rootYamlConfig } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createCommand(flags: { ref?: string }): WorkflowRun {
|
||||
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
|
||||
// @ts-expect-error getContextAsync/parse are protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
getDynamicPrivateProjectConfigAsync: async () => ({
|
||||
projectId: 'app-1',
|
||||
exp: { slug: 'testapp' },
|
||||
}),
|
||||
loggedIn: { graphqlClient: {} },
|
||||
vcsClient: { getRootPathAsync: async () => '/test/project' },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
// @ts-expect-error parse is protected on the oclif base
|
||||
jest.spyOn(command, 'parse').mockResolvedValue({
|
||||
flags: {
|
||||
json: false,
|
||||
wait: false,
|
||||
'non-interactive': true,
|
||||
input: undefined,
|
||||
ref: flags.ref,
|
||||
ssh: false,
|
||||
'ssh-idle-timeout': undefined,
|
||||
},
|
||||
args: { file: '.eas/workflows/test.yml' },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('sends the collected files to validation and to the run creation', async () => {
|
||||
await expect(createCommand({}).runAsync()).rejects.toThrow('process.exit:0');
|
||||
|
||||
expect(mockCollectReferencedFiles).toHaveBeenCalledWith({
|
||||
projectDir: '/test/project',
|
||||
rootFileName: 'test.yml',
|
||||
rootYamlConfig,
|
||||
});
|
||||
expect(mockValidate).toHaveBeenCalledWith(
|
||||
{},
|
||||
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles }
|
||||
);
|
||||
expect(mockCreateRun).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
workflowRevisionInput: {
|
||||
fileName: 'test.yml',
|
||||
yamlConfig: rootYamlConfig,
|
||||
referencedFiles,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before validation and upload when the collector throws', async () => {
|
||||
mockCollectReferencedFiles.mockRejectedValue(new Error('build.yml does not exist.'));
|
||||
|
||||
await expect(createCommand({}).runAsync()).rejects.toThrow('build.yml does not exist.');
|
||||
|
||||
expect(mockValidate).not.toHaveBeenCalled();
|
||||
expect(mockUploadProject).not.toHaveBeenCalled();
|
||||
expect(mockCreateRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not collect local files when --ref is set', async () => {
|
||||
await expect(createCommand({ ref: 'main' }).runAsync()).rejects.toThrow('process.exit:0');
|
||||
|
||||
expect(mockCollectReferencedFiles).not.toHaveBeenCalled();
|
||||
expect(mockValidate).toHaveBeenCalledWith(
|
||||
{},
|
||||
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles: undefined }
|
||||
);
|
||||
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({ workflowRevisionId: 'rev-1', gitRef: 'abc123' })
|
||||
);
|
||||
expect(mockCreateRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { promptAsync } from '../../prompts';
|
||||
|
||||
export default class WorkflowRunCancel extends EasCommand {
|
||||
static override description =
|
||||
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel';
|
||||
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select in-progress or queued runs to cancel';
|
||||
|
||||
static override strict = false;
|
||||
|
||||
@@ -22,21 +22,10 @@ export default class WorkflowRunCancel extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { argv } = await this.parse(WorkflowRunCancel);
|
||||
let nonInteractive = false;
|
||||
const workflowRunIds: Set<string> = new Set();
|
||||
|
||||
// Custom parsing of argv
|
||||
const tokens = [...argv] as string[];
|
||||
while (tokens.length > 0) {
|
||||
const token = tokens.shift();
|
||||
if (token === '--non-interactive') {
|
||||
nonInteractive = true;
|
||||
continue;
|
||||
} else if (token) {
|
||||
workflowRunIds.add(token);
|
||||
}
|
||||
}
|
||||
const { argv, flags } = await this.parse(WorkflowRunCancel);
|
||||
// oclif removes declared flags such as --non-interactive from argv.
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const workflowRunIds = new Set(argv as string[]);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -49,14 +38,13 @@ export default class WorkflowRunCancel extends EasCommand {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
|
||||
}
|
||||
// Run the workflow run list query and select runs to cancel
|
||||
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
WorkflowRunStatus.InProgress,
|
||||
50
|
||||
const pickerStatuses = [WorkflowRunStatus.InProgress, WorkflowRunStatus.Waiting];
|
||||
const runsByStatus = await Promise.all(
|
||||
pickerStatuses.map(status =>
|
||||
AppQuery.byIdWorkflowRunsFilteredByStatusAsync(graphqlClient, projectId, status, 50)
|
||||
)
|
||||
);
|
||||
const runs = processWorkflowRuns(queryResult);
|
||||
const runs = processWorkflowRuns(runsByStatus.flat());
|
||||
if (runs.length === 0) {
|
||||
Log.warn('No workflow runs to cancel');
|
||||
return;
|
||||
@@ -64,7 +52,7 @@ export default class WorkflowRunCancel extends EasCommand {
|
||||
const answers = await promptAsync({
|
||||
type: 'multiselect',
|
||||
name: 'selectedRuns',
|
||||
message: 'Select IN_PROGRESS workflow runs to cancel',
|
||||
message: 'Select workflow runs to cancel',
|
||||
choices: runs.map(run => choiceFromWorkflowRun(run)),
|
||||
});
|
||||
answers.selectedRuns.forEach((id: string) => {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
parseJsonInputs,
|
||||
parseWorkflowInputsFromYaml,
|
||||
} from '../../commandUtils/workflow/inputs';
|
||||
import { collectReferencedWorkflowFilesAsync } from '../../commandUtils/workflow/referencedFiles';
|
||||
import {
|
||||
fileExistsAsync,
|
||||
maybeReadStdinAsync,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
} from '../../commandUtils/workflow/utils';
|
||||
import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowReferencedFileInput,
|
||||
WorkflowRevision,
|
||||
WorkflowRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
@@ -179,6 +181,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
let workflowRunId: string;
|
||||
let workflowRevisionId: string | undefined;
|
||||
let gitRef: string | undefined;
|
||||
let referencedFiles: WorkflowReferencedFileInput[] | undefined;
|
||||
|
||||
if (flags.ref) {
|
||||
// Run from git ref
|
||||
@@ -230,6 +233,11 @@ export default class WorkflowRun extends EasCommand {
|
||||
|
||||
throw err;
|
||||
}
|
||||
referencedFiles = await collectReferencedWorkflowFilesAsync({
|
||||
projectDir,
|
||||
rootFileName: path.basename(args.file),
|
||||
rootYamlConfig: yamlConfig,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate workflow YAML
|
||||
@@ -237,6 +245,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
yamlConfig,
|
||||
referencedFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CombinedError) {
|
||||
@@ -362,6 +371,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
workflowRevisionInput: {
|
||||
fileName: path.basename(args.file),
|
||||
yamlConfig,
|
||||
referencedFiles,
|
||||
},
|
||||
workflowRunInput: {
|
||||
inputs,
|
||||
|
||||
@@ -84,7 +84,7 @@ export default class WorkflowRunList extends EasCommand {
|
||||
{ label: 'Workflow', value: run.workflowFileName },
|
||||
{ label: 'Status', value: run.status },
|
||||
{ label: 'Started At', value: run.startedAt },
|
||||
{ label: 'Finished At', value: run.finishedAt },
|
||||
{ label: 'Finished At', value: run.finishedAt ?? 'null' },
|
||||
{ label: 'Trigger Type', value: run.triggerType },
|
||||
{ label: 'Trigger', value: run.trigger ?? 'null' },
|
||||
{ label: 'Git Commit Message', value: run.gitCommitMessage ?? 'null' },
|
||||
|
||||
+317
-9
@@ -199,8 +199,7 @@ export type Account = {
|
||||
* Whether the account turned on environment variable protection. When protection is on,
|
||||
* reading, using, creating, changing, or deleting an environment variable whose environments are
|
||||
* exclusively production needs the PUBLISH_PROTECTED permission, whatever its visibility, in
|
||||
* every project of the account. Expo can also turn protection on for an account; this field
|
||||
* does not show that.
|
||||
* every project of the account.
|
||||
*/
|
||||
environmentVariableProtectionEnabled: Scalars['Boolean']['output'];
|
||||
/** Environment variables for an account */
|
||||
@@ -1100,6 +1099,7 @@ export type ActivityTimelineProjectActivity = {
|
||||
|
||||
export enum ActivityTimelineProjectActivityType {
|
||||
Build = 'BUILD',
|
||||
DeviceRunSession = 'DEVICE_RUN_SESSION',
|
||||
Submission = 'SUBMISSION',
|
||||
Update = 'UPDATE',
|
||||
Worker = 'WORKER',
|
||||
@@ -2487,11 +2487,13 @@ export type AppObserve = {
|
||||
* @deprecated Use overview.stability instead.
|
||||
*/
|
||||
overviewStability: AppObserveOverviewStability;
|
||||
/** One session's metrics and logs for the timeline (ENG-26160). Unknown session ids yield empty lists. */
|
||||
/** One session's metrics, logs and spans for the timeline (ENG-26160). Unknown session ids yield empty lists. */
|
||||
session: AppObserveSession;
|
||||
/** @deprecated Use metrics.timeSeries instead. */
|
||||
timeSeries: AppObserveTimeSeries;
|
||||
totalEventCount: Scalars['Int']['output'];
|
||||
/** Trace spans: today the HTTP request spans recorded with the SDK's networkTraces option (ENG-28448). */
|
||||
traces: AppObserveTraces;
|
||||
/**
|
||||
* Approximate count of unique users (`eas_client_id`) with at least one
|
||||
* supported-metric event in `[startTime, endTime)`. Uses a ClickHouse
|
||||
@@ -3923,6 +3925,8 @@ export type AppObserveSession = {
|
||||
logs: AppObserveLogConnection;
|
||||
/** Metric samples in the session. */
|
||||
metrics: AppObserveMetricConnection;
|
||||
/** Trace spans in the session. Defaults to oldest first. */
|
||||
spans: AppObserveSpanConnection;
|
||||
};
|
||||
|
||||
|
||||
@@ -3943,6 +3947,194 @@ export type AppObserveSession_MetricsArgs = {
|
||||
orderBy?: InputMaybe<AppObserveMetricsListOrderBy>;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveSession_SpansArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<AppObserveSessionSpanFilter>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
};
|
||||
|
||||
/** Filters for the spans of one session. The session already bounds the query. */
|
||||
export type AppObserveSessionSpanFilter = {
|
||||
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
|
||||
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
|
||||
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
|
||||
names?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
|
||||
};
|
||||
|
||||
export type AppObserveSpan = {
|
||||
__typename?: 'AppObserveSpan';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
appEasBuildId?: Maybe<Scalars['String']['output']>;
|
||||
appIdentifier: Scalars['String']['output'];
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
/** Span attributes other than those extracted to dedicated fields. */
|
||||
attributes: Array<AppObserveEventProperty>;
|
||||
clientVersion?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
|
||||
deviceModel: Scalars['String']['output'];
|
||||
deviceOs: Scalars['String']['output'];
|
||||
deviceOsVersion: Scalars['String']['output'];
|
||||
droppedAttributesCount: Scalars['Int']['output'];
|
||||
droppedEventsCount: Scalars['Int']['output'];
|
||||
/** Span links are not stored; this counts the links received. */
|
||||
droppedLinksCount: Scalars['Int']['output'];
|
||||
/** Keeps sub-millisecond precision. */
|
||||
durationMs: Scalars['Float']['output'];
|
||||
easClientId: Scalars['String']['output'];
|
||||
endTimestamp: Scalars['DateTime']['output'];
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
events: Array<AppObserveSpanEvent>;
|
||||
expoSdkVersion?: Maybe<Scalars['String']['output']>;
|
||||
/** Null when the span is not an HTTP request span. */
|
||||
http?: Maybe<AppObserveSpanHttp>;
|
||||
id: Scalars['ID']['output'];
|
||||
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
kind: AppObserveSpanKind;
|
||||
/** Per the producer's semantic conventions: the HTTP method for request spans. */
|
||||
name: Scalars['String']['output'];
|
||||
/** Null for a root span. */
|
||||
parentSpanId?: Maybe<Scalars['String']['output']>;
|
||||
reactNativeVersion?: Maybe<Scalars['String']['output']>;
|
||||
sessionId?: Maybe<Scalars['String']['output']>;
|
||||
spanId: Scalars['String']['output'];
|
||||
startTimestamp: Scalars['DateTime']['output'];
|
||||
statusCode: AppObserveSpanStatusCode;
|
||||
statusMessage?: Maybe<Scalars['String']['output']>;
|
||||
traceId: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveSpanAttributeFilter = {
|
||||
key: Scalars['String']['input'];
|
||||
value: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveSpanConnection = {
|
||||
__typename?: 'AppObserveSpanConnection';
|
||||
edges: Array<AppObserveSpanEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppObserveSpanEdge = {
|
||||
__typename?: 'AppObserveSpanEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: AppObserveSpan;
|
||||
};
|
||||
|
||||
export type AppObserveSpanEvent = {
|
||||
__typename?: 'AppObserveSpanEvent';
|
||||
/** The event's attributes as a JSON object. */
|
||||
attributes: Scalars['JSON']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
timestamp: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
/** HTTP request fields of a span, from the OpenTelemetry HTTP client semantic conventions. */
|
||||
export type AppObserveSpanHttp = {
|
||||
__typename?: 'AppObserveSpanHttp';
|
||||
/** Set when the request failed before a response: an NSURLError code on iOS, an exception class on Android. */
|
||||
errorType?: Maybe<Scalars['String']['output']>;
|
||||
method?: Maybe<Scalars['String']['output']>;
|
||||
protocolVersion?: Maybe<Scalars['String']['output']>;
|
||||
/** On-the-wire bytes including headers. */
|
||||
requestSize?: Maybe<Scalars['Int']['output']>;
|
||||
responseSize?: Maybe<Scalars['Int']['output']>;
|
||||
/** Null when the request got no response. */
|
||||
statusCode?: Maybe<Scalars['Int']['output']>;
|
||||
/** Credentials and signing query parameters are redacted at ingestion. */
|
||||
urlFull?: Maybe<Scalars['String']['output']>;
|
||||
urlHost?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum AppObserveSpanKind {
|
||||
Client = 'CLIENT',
|
||||
Consumer = 'CONSUMER',
|
||||
Internal = 'INTERNAL',
|
||||
Producer = 'PRODUCER',
|
||||
Server = 'SERVER',
|
||||
Unspecified = 'UNSPECIFIED'
|
||||
}
|
||||
|
||||
export type AppObserveSpanListFilter = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
/** At most 5. Matches spans whose attribute has exactly this value. */
|
||||
attributeFilters?: InputMaybe<Array<AppObserveSpanAttributeFilter>>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
/** HTTP request spans to any of these hosts. */
|
||||
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** HTTP request spans with any of these response status codes. */
|
||||
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
|
||||
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
|
||||
/** Span names, such as GET or POST. The cheapest filter: span name is in the table's sort key. */
|
||||
names?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** Filter to these platforms. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
/** Only spans with no parent. */
|
||||
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
|
||||
/** 32-character hex trace id. */
|
||||
traceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AppObserveSpanListOrderBy = {
|
||||
direction: AppObserveOrderDirection;
|
||||
field: AppObserveSpanListOrderByField;
|
||||
};
|
||||
|
||||
export enum AppObserveSpanListOrderByField {
|
||||
StartTimestamp = 'START_TIMESTAMP'
|
||||
}
|
||||
|
||||
export type AppObserveSpanName = {
|
||||
__typename?: 'AppObserveSpanName';
|
||||
count: Scalars['Int']['output'];
|
||||
kind: AppObserveSpanKind;
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveSpanNames = {
|
||||
__typename?: 'AppObserveSpanNames';
|
||||
/** True when more names exist than were returned (top-N truncation). */
|
||||
isTruncated: Scalars['Boolean']['output'];
|
||||
names: Array<AppObserveSpanName>;
|
||||
};
|
||||
|
||||
export type AppObserveSpanNamesInput = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** Maximum names to return, 1 to 500. Defaults to 100. */
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** Filter to these platforms. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export enum AppObserveSpanStatusCode {
|
||||
Error = 'ERROR',
|
||||
Ok = 'OK',
|
||||
Unset = 'UNSET'
|
||||
}
|
||||
|
||||
/** Stability-tile filters. No release filters: the tiles always span all versions. */
|
||||
export type AppObserveStabilityInput = {
|
||||
/** Series bucket size. Defaults to one day. */
|
||||
@@ -4006,6 +4198,70 @@ export type AppObserveTimeSeriesStatistics = {
|
||||
p99?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export type AppObserveTrace = {
|
||||
__typename?: 'AppObserveTrace';
|
||||
id: Scalars['ID']['output'];
|
||||
/** True when the trace has more spans than were returned. */
|
||||
isTruncated: Scalars['Boolean']['output'];
|
||||
/** Oldest first. The root span, when present, is the one with no parent. */
|
||||
spans: Array<AppObserveSpan>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
|
||||
* API is generic so other span producers can be added without a schema change.
|
||||
*/
|
||||
export type AppObserveTraces = {
|
||||
__typename?: 'AppObserveTraces';
|
||||
/** Distinct span names and kinds with counts over the time range, most frequent first. */
|
||||
names: AppObserveSpanNames;
|
||||
/** A single span by id. Null when missing or aged out of retention. */
|
||||
span?: Maybe<AppObserveSpan>;
|
||||
/** Individual spans. Defaults to newest first. */
|
||||
spans: AppObserveSpanConnection;
|
||||
/** All spans of one trace by its 32-character hex trace id. Null when unknown or aged out of retention. */
|
||||
trace?: Maybe<AppObserveTrace>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
|
||||
* API is generic so other span producers can be added without a schema change.
|
||||
*/
|
||||
export type AppObserveTraces_NamesArgs = {
|
||||
input: AppObserveSpanNamesInput;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
|
||||
* API is generic so other span producers can be added without a schema change.
|
||||
*/
|
||||
export type AppObserveTraces_SpanArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
|
||||
* API is generic so other span producers can be added without a schema change.
|
||||
*/
|
||||
export type AppObserveTraces_SpansArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
filter: AppObserveSpanListFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
|
||||
* API is generic so other span producers can be added without a schema change.
|
||||
*/
|
||||
export type AppObserveTraces_TraceArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveUpdate = {
|
||||
__typename?: 'AppObserveUpdate';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
@@ -7104,8 +7360,10 @@ export type DeploymentsMutation_DeleteWorkerDeploymentByIdentifierArgs = {
|
||||
deploymentIdentifier: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSession = {
|
||||
export type DeviceRunSession = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'DeviceRunSession';
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
actor?: Maybe<Actor>;
|
||||
app: App;
|
||||
/**
|
||||
* Direct application archive URL installed and launched for this session. Null when
|
||||
@@ -13923,6 +14181,13 @@ export type WorkflowJob = {
|
||||
__typename?: 'WorkflowJob';
|
||||
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
|
||||
approvals: Array<WorkflowJobApproval>;
|
||||
/**
|
||||
* The workflow run started by a WORKFLOW_CALL job. Null for other job types, while the
|
||||
* called run is not created yet, and for a failed call that never started a run. A call
|
||||
* job copied by "re-run from failed jobs" resolves to the called run of the attempt it
|
||||
* was copied from.
|
||||
*/
|
||||
childWorkflowRun?: Maybe<WorkflowRun>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
|
||||
/**
|
||||
@@ -14058,6 +14323,7 @@ export enum WorkflowJobType {
|
||||
Custom = 'CUSTOM',
|
||||
Deploy = 'DEPLOY',
|
||||
Doc = 'DOC',
|
||||
Email = 'EMAIL',
|
||||
Fingerprint = 'FINGERPRINT',
|
||||
GetBuild = 'GET_BUILD',
|
||||
GithubComment = 'GITHUB_COMMENT',
|
||||
@@ -14104,6 +14370,11 @@ export type WorkflowQuery_ByIdArgs = {
|
||||
workflowId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type WorkflowReferencedFileInput = {
|
||||
fileName: Scalars['String']['input'];
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type WorkflowRevision = {
|
||||
__typename?: 'WorkflowRevision';
|
||||
blobSha: Scalars['String']['output'];
|
||||
@@ -14123,6 +14394,12 @@ export type WorkflowRevisionEdge = {
|
||||
|
||||
export type WorkflowRevisionInput = {
|
||||
fileName: Scalars['String']['input'];
|
||||
/**
|
||||
* Other workflow files of the project that `uses` jobs reference, by bare file name.
|
||||
* Required when the workflow has `uses` jobs. Every uploaded file is stored as a
|
||||
* revision and pinned on the run, whether or not a job calls it.
|
||||
*/
|
||||
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
@@ -14149,6 +14426,7 @@ export type WorkflowRevisionMutation_GetWorkflowRevisionsFromGitRefArgs = {
|
||||
|
||||
export type WorkflowRevisionMutation_ValidateWorkflowYamlConfigArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
@@ -14173,6 +14451,12 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
actor?: Maybe<Actor>;
|
||||
app: App;
|
||||
/**
|
||||
* The run currently holding the concurrency group this run is waiting for.
|
||||
* Null for runs that are not waiting, and briefly null while the group's
|
||||
* next run is being promoted.
|
||||
*/
|
||||
blockingWorkflowRun?: Maybe<WorkflowRun>;
|
||||
/**
|
||||
* Why the server canceled this run. Null for manually canceled runs and for
|
||||
* runs canceled before the reason was recorded.
|
||||
@@ -14192,6 +14476,11 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
jobs: Array<WorkflowJob>;
|
||||
/** Snapshot of the workflow's name at run creation. */
|
||||
name: Scalars['String']['output'];
|
||||
/**
|
||||
* The WORKFLOW_CALL job of another run that started this run. Null for runs started by
|
||||
* a trigger, a manual dispatch or a retry. Called runs are omitted from run lists.
|
||||
*/
|
||||
parentWorkflowJob?: Maybe<WorkflowJob>;
|
||||
pullRequestNumber?: Maybe<Scalars['Int']['output']>;
|
||||
requestedGitRef?: Maybe<Scalars['String']['output']>;
|
||||
retriedWorkflowRun?: Maybe<WorkflowRun>;
|
||||
@@ -14207,7 +14496,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
workflowRevision?: Maybe<WorkflowRevision>;
|
||||
};
|
||||
|
||||
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonWorkflowCall;
|
||||
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonConcurrencyGroupQueueFull | WorkflowRunCancelReasonConcurrencyGroupReplaced | WorkflowRunCancelReasonWorkflowCall;
|
||||
|
||||
export type WorkflowRunCancelReasonConcurrencyGroup = {
|
||||
__typename?: 'WorkflowRunCancelReasonConcurrencyGroup';
|
||||
@@ -14218,6 +14507,24 @@ export type WorkflowRunCancelReasonConcurrencyGroup = {
|
||||
cancelingWorkflowRun?: Maybe<WorkflowRun>;
|
||||
};
|
||||
|
||||
export type WorkflowRunCancelReasonConcurrencyGroupQueueFull = {
|
||||
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupQueueFull';
|
||||
/**
|
||||
* The waiting-run capacity the concurrency group's queue was at when this
|
||||
* run arrived.
|
||||
*/
|
||||
maximumWaitingRuns: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowRunCancelReasonConcurrencyGroupReplaced = {
|
||||
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupReplaced';
|
||||
/**
|
||||
* The newer run that took this run's place in the concurrency group's waiting
|
||||
* slot. Null if that run no longer exists.
|
||||
*/
|
||||
replacingWorkflowRun?: Maybe<WorkflowRun>;
|
||||
};
|
||||
|
||||
export type WorkflowRunCancelReasonWorkflowCall = {
|
||||
__typename?: 'WorkflowRunCancelReasonWorkflowCall';
|
||||
/**
|
||||
@@ -15624,6 +15931,7 @@ export type GetOrCreateWorkflowRevisionFromGitRefMutation = { __typename?: 'Root
|
||||
export type ValidateWorkflowYamlConfigMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput> | WorkflowReferencedFileInput>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -15752,7 +16060,7 @@ export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
|
||||
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
|
||||
| { __typename: 'PartnerActor', id: string }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string }
|
||||
| { __typename: 'SSOUser', username: string, id: string }
|
||||
@@ -16598,7 +16906,7 @@ export type WorkflowRunByIdWithJobsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
|
||||
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, blockingWorkflowRun?: { __typename?: 'WorkflowRun', id: string, name: string, status: WorkflowRunStatus } | null, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
|
||||
| { __typename: 'PartnerActor', id: string, displayName: string }
|
||||
| { __typename: 'Robot', id: string, displayName: string }
|
||||
| { __typename: 'SSOUser', id: string, displayName: string }
|
||||
@@ -16618,7 +16926,7 @@ export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
|
||||
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
|
||||
| { __typename: 'PartnerActor', id: string }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string }
|
||||
| { __typename: 'SSOUser', username: string, id: string }
|
||||
@@ -16810,7 +17118,7 @@ export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key:
|
||||
| { __typename: 'User', id: string, displayName: string }
|
||||
| null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
|
||||
|
||||
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
|
||||
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
|
||||
| { __typename: 'PartnerActor', id: string }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string }
|
||||
| { __typename: 'SSOUser', username: string, id: string }
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
|
||||
ValidateWorkflowYamlConfigMutation,
|
||||
ValidateWorkflowYamlConfigMutationVariables,
|
||||
WorkflowReferencedFileInput,
|
||||
WorkflowRevision,
|
||||
} from '../generated';
|
||||
|
||||
@@ -72,24 +73,35 @@ export namespace WorkflowRevisionMutation {
|
||||
{
|
||||
appId,
|
||||
yamlConfig,
|
||||
referencedFiles,
|
||||
}: {
|
||||
appId: string;
|
||||
yamlConfig: string;
|
||||
referencedFiles?: WorkflowReferencedFileInput[];
|
||||
}
|
||||
): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ValidateWorkflowYamlConfigMutation, ValidateWorkflowYamlConfigMutationVariables>(
|
||||
gql`
|
||||
mutation ValidateWorkflowYamlConfig($appId: ID!, $yamlConfig: String!) {
|
||||
mutation ValidateWorkflowYamlConfig(
|
||||
$appId: ID!
|
||||
$yamlConfig: String!
|
||||
$referencedFiles: [WorkflowReferencedFileInput!]
|
||||
) {
|
||||
workflowRevision {
|
||||
validateWorkflowYamlConfig(appId: $appId, yamlConfig: $yamlConfig)
|
||||
validateWorkflowYamlConfig(
|
||||
appId: $appId
|
||||
yamlConfig: $yamlConfig
|
||||
referencedFiles: $referencedFiles
|
||||
)
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
yamlConfig,
|
||||
referencedFiles,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
|
||||
@@ -118,11 +118,17 @@ export const WorkflowRunQuery = {
|
||||
workflowRuns {
|
||||
byId(workflowRunId: $workflowRunId) {
|
||||
id
|
||||
blockingWorkflowRun {
|
||||
id
|
||||
name
|
||||
status
|
||||
}
|
||||
workflow {
|
||||
id
|
||||
app {
|
||||
id
|
||||
name
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
|
||||
@@ -22,6 +22,7 @@ export const WorkflowRunFragmentNode = gql`
|
||||
triggeringSchedule
|
||||
createdAt
|
||||
updatedAt
|
||||
finalizedAt
|
||||
errors {
|
||||
title
|
||||
message
|
||||
|
||||
@@ -45,6 +45,12 @@ export default class Log {
|
||||
console.error(...Log.withTextColor(args, chalk.stderr.red));
|
||||
}
|
||||
|
||||
public static warnToStderr(...args: any[]): void {
|
||||
Log.updateIsLastLineNewLine(args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(...Log.withTextColor(args, chalk.stderr.yellow));
|
||||
}
|
||||
|
||||
public static warn(...args: any[]): void {
|
||||
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
|
||||
}
|
||||
|
||||
@@ -466,6 +466,7 @@ function colorWorkflowStatus(status: string): string {
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
return chalk.yellow('action required');
|
||||
case WorkflowRunStatus.New:
|
||||
case WorkflowRunStatus.Waiting:
|
||||
case WorkflowRunStatus.InProgress:
|
||||
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import fs from 'fs-extra';
|
||||
import { createServer } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import fetch, { Response } from '../../fetch';
|
||||
import { DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
type SimulatorSession,
|
||||
downloadSimulatorArtifactAsync,
|
||||
hasSimulatorArtifacts,
|
||||
} from '../artifacts';
|
||||
import { downloadSimulatorFileAsync } from '../download';
|
||||
|
||||
jest.mock('../../fetch');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: () => ({ start: () => ({ succeed: jest.fn(), fail: jest.fn() }) }),
|
||||
}));
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../download');
|
||||
|
||||
function artifact(
|
||||
id: string,
|
||||
type = 'simulator-log',
|
||||
createdAt = '2026-10-07T12:00:00Z'
|
||||
): SimulatorSession['artifacts'][number] {
|
||||
return {
|
||||
id,
|
||||
name: `Log ${id}`,
|
||||
filename: `${id}.ndjson`,
|
||||
downloadUrl: `https://storage.test/${id}?token=secret`,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
fileSizeBytes: 100,
|
||||
metadata: { __eas_type: type, udid: 'SIM-A', part: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
const first = artifact('a');
|
||||
function session(artifacts = [first]): SimulatorSession {
|
||||
return {
|
||||
id: 'session-id',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
artifacts,
|
||||
} as SimulatorSession;
|
||||
}
|
||||
|
||||
const options = { output: 'logs.ndjson', nonInteractive: true, json: false };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(downloadSimulatorFileAsync).mockResolvedValue('/logs.ndjson');
|
||||
});
|
||||
|
||||
it.each([DeviceRunSessionStatus.Stopped, DeviceRunSessionStatus.Errored])(
|
||||
'uses artifacts for %s',
|
||||
status => {
|
||||
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
DeviceRunSessionStatus.Queued,
|
||||
DeviceRunSessionStatus.Starting,
|
||||
])('does not use artifacts for %s', status => {
|
||||
expect(hasSimulatorArtifacts({ ...session(), status })).toBe(false);
|
||||
});
|
||||
|
||||
it('reports missing artifacts without contacting storage', async () => {
|
||||
await expect(
|
||||
downloadSimulatorArtifactAsync(
|
||||
session([artifact('a', 'session-events')]),
|
||||
'simulator-log',
|
||||
options
|
||||
)
|
||||
).rejects.toThrow('No matching artifacts');
|
||||
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('automatically selects one artifact and prints its saved path', async () => {
|
||||
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
|
||||
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('logs.ndjson', expect.any(Function));
|
||||
expect(Log.log).toHaveBeenCalledWith('Saved Log a to /logs.ndjson.');
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sorts available indexes, sanitizes labels, and requires explicit selection noninteractively', async () => {
|
||||
const older = {
|
||||
...artifact('b', 'simulator-log', '2026-10-07T11:00:00Z'),
|
||||
name: '\u001b[31mEarlier\u001b[0m\u0007',
|
||||
};
|
||||
await expect(
|
||||
downloadSimulatorArtifactAsync(session([first, older]), 'simulator-log', options)
|
||||
).rejects.toThrow(
|
||||
'1. Earlier — b.ndjson (100 B) (SIM-A) (part 2)\n2. Log a — a.ndjson (100 B) (SIM-A) (part 2)'
|
||||
);
|
||||
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, -1, 3, 1.5])('rejects invalid index %s and lists indexes', async index => {
|
||||
await expect(
|
||||
downloadSimulatorArtifactAsync(session([first, artifact('b')]), 'simulator-log', {
|
||||
...options,
|
||||
artifact: index,
|
||||
})
|
||||
).rejects.toThrow('out of range. Choose an artifact with --artifact');
|
||||
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the explicit 1-based index and returns metadata without storage credentials', async () => {
|
||||
const second = artifact('b');
|
||||
await downloadSimulatorArtifactAsync(session([second, first]), 'simulator-log', {
|
||||
...options,
|
||||
artifact: 2,
|
||||
json: true,
|
||||
});
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifact: {
|
||||
id: second.id,
|
||||
name: second.name,
|
||||
filename: second.filename,
|
||||
metadata: second.metadata,
|
||||
},
|
||||
filePath: '/logs.ndjson',
|
||||
});
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(jest.mocked(printJsonOnlyOutput).mock.calls)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('prompts for a selection and destination with a basename default', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValue(2);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ filePath: 'chosen.ndjson' });
|
||||
await downloadSimulatorArtifactAsync(
|
||||
session([first, { ...artifact('b'), filename: '../saved.ndjson' }]),
|
||||
'simulator-log',
|
||||
{ nonInteractive: false, json: false }
|
||||
);
|
||||
expect(selectAsync).toHaveBeenCalledWith('Select an artifact to download:', [
|
||||
{ title: '1. Log a — a.ndjson (100 B) (SIM-A) (part 2)', value: 1 },
|
||||
{ title: '2. Log b — ../saved.ndjson (100 B) (SIM-A) (part 2)', value: 2 },
|
||||
]);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ initial: 'saved.ndjson' }));
|
||||
expect(downloadSimulatorFileAsync).toHaveBeenCalledWith('chosen.ndjson', expect.any(Function));
|
||||
});
|
||||
|
||||
it('requires output without prompting in noninteractive mode', async () => {
|
||||
await expect(
|
||||
downloadSimulatorArtifactAsync(session(), 'simulator-log', { nonInteractive: true, json: true })
|
||||
).rejects.toThrow('Pass --output');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(downloadSimulatorFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads from storage without a preview bearer or redirect restrictions', async () => {
|
||||
const response = new Response(Readable.from(['logs']));
|
||||
jest.mocked(fetch).mockResolvedValue(response);
|
||||
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
|
||||
await expect(getResponse(new AbortController().signal)).resolves.toBe(response);
|
||||
return '/logs.ndjson';
|
||||
});
|
||||
await downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
|
||||
expect(fetch).toHaveBeenCalledWith(first.downloadUrl, {
|
||||
signal: expect.any(AbortSignal),
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts signed URLs from storage failures', async () => {
|
||||
jest.mocked(fetch).mockRejectedValue(new Error(first.downloadUrl));
|
||||
jest.mocked(downloadSimulatorFileAsync).mockImplementation(async (_output, getResponse) => {
|
||||
await getResponse(new AbortController().signal);
|
||||
return '/logs.ndjson';
|
||||
});
|
||||
const downloading = downloadSimulatorArtifactAsync(session(), 'simulator-log', options);
|
||||
await expect(downloading).rejects.toThrow('Could not download the session artifact');
|
||||
await expect(downloading).rejects.not.toThrow('secret');
|
||||
});
|
||||
|
||||
it('closes an unfinished HTTP error response without exposing storage credentials', async () => {
|
||||
const actualFetch = jest.requireActual<typeof import('../../fetch')>('../../fetch').default;
|
||||
const actualDownload = jest.requireActual<typeof import('../download')>('../download');
|
||||
jest.mocked(fetch).mockImplementation(actualFetch);
|
||||
jest
|
||||
.mocked(downloadSimulatorFileAsync)
|
||||
.mockImplementation(actualDownload.downloadSimulatorFileAsync);
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-error-'));
|
||||
let markClosed = (): void => {};
|
||||
const closed = new Promise<void>(resolve => {
|
||||
markClosed = resolve;
|
||||
});
|
||||
const server = createServer((_request, response) => {
|
||||
response.once('close', markClosed);
|
||||
response.writeHead(403);
|
||||
response.write('secret storage token');
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const failedArtifact = {
|
||||
...first,
|
||||
downloadUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/?token=secret`,
|
||||
};
|
||||
let expired = false;
|
||||
const deadline = setTimeout(() => {
|
||||
expired = true;
|
||||
server.closeAllConnections();
|
||||
}, 2_000);
|
||||
try {
|
||||
const downloading = downloadSimulatorArtifactAsync(session([failedArtifact]), 'simulator-log', {
|
||||
...options,
|
||||
output: path.join(directory, 'logs.ndjson'),
|
||||
});
|
||||
await expect(downloading).rejects.toThrow('Could not download the session artifact');
|
||||
await expect(downloading).rejects.not.toThrow('secret');
|
||||
await closed;
|
||||
expect(expired).toBe(false);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
await fs.remove(directory);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import fs from 'fs-extra';
|
||||
import { createServer } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import fetch, { Response } from '../../fetch';
|
||||
import { downloadSimulatorFileAsync } from '../download';
|
||||
|
||||
let directory: string;
|
||||
let output: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sim-artifact-test-'));
|
||||
output = path.join(directory, 'capture.har');
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.remove(directory);
|
||||
});
|
||||
|
||||
it('writes private files and removes its interrupt listener after success', async () => {
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['private'])))
|
||||
).resolves.toBe(output);
|
||||
expect(await fs.readFile(output, 'utf8')).toBe('private');
|
||||
expect((await fs.stat(output)).mode & 0o777).toBe(0o600);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('preserves an existing output file', async () => {
|
||||
await fs.writeFile(output, 'existing');
|
||||
await expect(
|
||||
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
|
||||
).rejects.toThrow('already exists');
|
||||
expect(await fs.readFile(output, 'utf8')).toBe('existing');
|
||||
});
|
||||
|
||||
it('refuses a symlink without changing its target', async () => {
|
||||
const target = path.join(directory, 'target');
|
||||
await fs.writeFile(target, 'existing');
|
||||
await fs.symlink(target, output);
|
||||
await expect(
|
||||
downloadSimulatorFileAsync(output, async () => new Response(Readable.from(['replacement'])))
|
||||
).rejects.toThrow('already exists');
|
||||
expect(await fs.readFile(target, 'utf8')).toBe('existing');
|
||||
expect((await fs.lstat(output)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a file created by a competing process during the request', async () => {
|
||||
await expect(
|
||||
downloadSimulatorFileAsync(output, async () => {
|
||||
await fs.writeFile(output, 'competitor');
|
||||
return new Response(Readable.from(['replacement']));
|
||||
})
|
||||
).rejects.toThrow('already exists');
|
||||
expect(await fs.readFile(output, 'utf8')).toBe('competitor');
|
||||
});
|
||||
|
||||
it('removes only its partial file after a body failure', async () => {
|
||||
const body = new PassThrough();
|
||||
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
|
||||
body.write('partial');
|
||||
while (!(await fs.pathExists(output))) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
body.destroy(new Error('secret storage URL'));
|
||||
await expect(downloading).rejects.toThrow('Could not save the download');
|
||||
expect(await fs.pathExists(output)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the file on an immediate response failure', async () => {
|
||||
const body = Readable.from(
|
||||
(async function* () {
|
||||
yield 'partial';
|
||||
throw new Error('secret storage URL');
|
||||
})()
|
||||
);
|
||||
const downloading = downloadSimulatorFileAsync(output, async () => new Response(body));
|
||||
await expect(downloading).rejects.toThrow('Could not save the download');
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing files on request failure', async () => {
|
||||
await expect(
|
||||
downloadSimulatorFileAsync(output, async () => {
|
||||
await fs.writeFile(output, 'existing');
|
||||
throw new Error('request failed');
|
||||
})
|
||||
).rejects.toThrow('request failed');
|
||||
expect(await fs.readFile(output, 'utf8')).toBe('existing');
|
||||
});
|
||||
|
||||
it('follows storage redirects and cancels a real HTTP download with full cleanup', async () => {
|
||||
let markClosed: () => void = () => {};
|
||||
const closed = new Promise<void>(resolve => {
|
||||
markClosed = resolve;
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === '/redirect') {
|
||||
response.writeHead(302, { location: '/body' });
|
||||
response.end();
|
||||
} else {
|
||||
response.once('close', markClosed);
|
||||
response.writeHead(200);
|
||||
response.write('partial');
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/redirect`;
|
||||
const listeners = process.listeners('SIGINT');
|
||||
try {
|
||||
const downloading = downloadSimulatorFileAsync(
|
||||
output,
|
||||
async signal => await fetch(url, { signal })
|
||||
);
|
||||
while (!(await fs.pathExists(output))) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
process.emit('SIGINT');
|
||||
await expect(downloading).rejects.toThrow('canceled');
|
||||
await closed;
|
||||
expect(await fs.pathExists(output)).toBe(false);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import fetch, { Headers, RequestError, Response } from '../../fetch';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import {
|
||||
fetchSimulatorPreviewAsync,
|
||||
fetchSimulatorPreviewJsonAsync,
|
||||
resolveSimulatorPreviewAsync,
|
||||
streamSimulatorPreviewAsync,
|
||||
} from '../preview';
|
||||
|
||||
jest.mock('../../fetch', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('../../fetch'),
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const preview = {
|
||||
deviceRunSessionId: 'session-id',
|
||||
baseUrl: new URL('https://preview.test'),
|
||||
token: 'secret-token',
|
||||
};
|
||||
|
||||
function session(
|
||||
remoteConfig: unknown,
|
||||
status = DeviceRunSessionStatus.InProgress
|
||||
): DeviceRunSessionByIdQuery['deviceRunSessions']['byId'] {
|
||||
return {
|
||||
id: 'session-id',
|
||||
status,
|
||||
remoteConfig,
|
||||
} as DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
}
|
||||
|
||||
function rejectWithStatus(status: number): void {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(
|
||||
new RequestError('not found', new Response(Readable.from(['']), { status }))
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe(resolveSimulatorPreviewAsync, () => {
|
||||
it.each([
|
||||
['ArgentRunSessionRemoteConfig', 'webPreviewToken'],
|
||||
['AgentDeviceRunSessionRemoteConfig', 'webPreviewToken'],
|
||||
['AppiumRunSessionRemoteConfig', 'webPreviewToken'],
|
||||
['ServeSimRunSessionRemoteConfig', 'previewToken'],
|
||||
['WebPreviewOnlyRunSessionRemoteConfig', 'previewToken'],
|
||||
])(
|
||||
'resolves preview authentication for %s without using controller credentials',
|
||||
async (__typename, tokenKey) => {
|
||||
const currentSession = session({
|
||||
__typename,
|
||||
previewApiUrl: 'https://preview.test/prefix/?device=SIM-A&token=old-token#fragment',
|
||||
[tokenKey]: 'preview-token',
|
||||
toolsAuthToken: 'wrong-controller-token',
|
||||
agentDeviceRemoteSessionToken: 'wrong-controller-token',
|
||||
});
|
||||
const resolved = await resolveSimulatorPreviewAsync(currentSession);
|
||||
expect(resolved.token).toBe('preview-token');
|
||||
expect(resolved.baseUrl.toString()).toBe('https://preview.test/prefix/?device=SIM-A');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[DeviceRunSessionStatus.Stopped, { previewApiUrl: 'https://preview.test' }, 'is not running'],
|
||||
[
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
{ webPreviewUrl: 'https://expo.dev/preview' },
|
||||
'does not expose a preview API',
|
||||
],
|
||||
[
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
{ previewApiUrl: 'not a URL', webPreviewToken: 'preview-token' },
|
||||
'invalid preview API URL',
|
||||
],
|
||||
[
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
{ previewApiUrl: 'file:///private/secret', webPreviewToken: 'preview-token' },
|
||||
'invalid preview API URL',
|
||||
],
|
||||
[
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
{ __typename: 'ArgentRunSessionRemoteConfig', previewApiUrl: 'https://preview.test' },
|
||||
'does not expose a preview API',
|
||||
],
|
||||
[
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
previewApiUrl: 'https://preview.test/?token=embedded',
|
||||
},
|
||||
'does not expose a preview API',
|
||||
],
|
||||
])('rejects unavailable or invalid preview configuration %#', async (status, config, message) => {
|
||||
await expect(resolveSimulatorPreviewAsync(session(config, status))).rejects.toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchSimulatorPreviewAsync, () => {
|
||||
it('requests the route on the preview API origin with header authentication', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('{}'));
|
||||
await fetchSimulatorPreviewAsync(preview, '/logs', { query: { snapshot: '1' } });
|
||||
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
|
||||
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
|
||||
expect(headers.get('authorization')).toBe('Bearer secret-token');
|
||||
});
|
||||
|
||||
it('rejects redirects before sending preview credentials to another server', async () => {
|
||||
const destination = jest.fn();
|
||||
const target = createServer((request, response) => {
|
||||
destination(request.headers.authorization);
|
||||
response.end('{}');
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
const source = createServer((_request, response) => {
|
||||
response.writeHead(302, {
|
||||
location: `http://127.0.0.1:${(target.address() as AddressInfo).port}/logs`,
|
||||
});
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>(resolve => source.listen(0, '127.0.0.1', resolve));
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
fetchSimulatorPreviewAsync(
|
||||
{
|
||||
...preview,
|
||||
baseUrl: new URL(`http://127.0.0.1:${(source.address() as AddressInfo).port}`),
|
||||
},
|
||||
'/logs'
|
||||
)
|
||||
).rejects.toThrow('Could not connect');
|
||||
expect(destination).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
source.closeAllConnections();
|
||||
target.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise<void>(resolve =>
|
||||
source.close(() => {
|
||||
resolve();
|
||||
})
|
||||
),
|
||||
new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an offline preview tunnel before interpreting the route failure', async () => {
|
||||
jest.mocked(fetch).mockRejectedValue(
|
||||
new RequestError(
|
||||
'https://preview.test/?token=secret-token',
|
||||
new Response(Readable.from(['secret response']), {
|
||||
status: 404,
|
||||
headers: { 'ngrok-error-code': 'ERR_NGROK_3200' },
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
fetchSimulatorPreviewAsync(preview, '/logs', { notFoundMessage: 'Not found here.' })
|
||||
).rejects.toThrow(
|
||||
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, 'refused access'],
|
||||
[403, 'refused access'],
|
||||
[404, 'does not support this request'],
|
||||
[500, 'HTTP 500'],
|
||||
])('reports HTTP %s without exposing response bodies or credentials', async (status, message) => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(
|
||||
new RequestError(
|
||||
'https://preview.test/?token=secret-token',
|
||||
new Response(Readable.from(['secret response']), { status })
|
||||
)
|
||||
);
|
||||
const error = fetchSimulatorPreviewAsync(preview, '/logs');
|
||||
await expect(error).rejects.toThrow(message);
|
||||
await expect(error).rejects.not.toThrow('secret');
|
||||
});
|
||||
|
||||
it('closes the connection of an unfinished error response and keeps its body out of the error', async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(403, { 'content-type': 'text/plain' });
|
||||
response.write('secret response that never ends');
|
||||
});
|
||||
const closed = new Promise<void>(resolve => {
|
||||
server.once('connection', socket => socket.once('close', resolve));
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
|
||||
);
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const error = fetchSimulatorPreviewJsonAsync(
|
||||
{
|
||||
...preview,
|
||||
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
|
||||
},
|
||||
'/logs',
|
||||
{ query: { snapshot: '1' } }
|
||||
);
|
||||
await expect(error).rejects.toThrow('refused access');
|
||||
await expect(error).rejects.not.toThrow('secret');
|
||||
await Promise.race([
|
||||
closed,
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('The error response connection stayed open.'));
|
||||
}, 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the caller message for a missing route', async () => {
|
||||
rejectWithStatus(404);
|
||||
await expect(
|
||||
fetchSimulatorPreviewAsync(preview, '/crashes/old-id', {
|
||||
notFoundMessage: 'The crash report was not found.',
|
||||
})
|
||||
).rejects.toThrow('The crash report was not found.');
|
||||
});
|
||||
|
||||
it('does not expose URLs in connection errors', async () => {
|
||||
jest.mocked(fetch).mockRejectedValueOnce(new Error('https://preview.test/?token=secret-token'));
|
||||
await expect(fetchSimulatorPreviewAsync(preview, '/logs')).rejects.toThrow(
|
||||
'Could not connect to the simulator preview API.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchSimulatorPreviewJsonAsync, () => {
|
||||
it('requests JSON and returns the parsed response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('{"lines":[]}'));
|
||||
await expect(
|
||||
fetchSimulatorPreviewJsonAsync(preview, '/logs', { query: { snapshot: '1' } })
|
||||
).resolves.toEqual({ lines: [] });
|
||||
expect(fetch).toHaveBeenCalledWith('https://preview.test/logs?snapshot=1', expect.anything());
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
|
||||
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
|
||||
expect(headers.get('accept')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('passes the caller message for a missing route', async () => {
|
||||
rejectWithStatus(404);
|
||||
await expect(
|
||||
fetchSimulatorPreviewJsonAsync(preview, '/crashes/old-id', {
|
||||
notFoundMessage: 'The crash report was not found.',
|
||||
})
|
||||
).rejects.toThrow('The crash report was not found.');
|
||||
});
|
||||
|
||||
it('does not expose response data in invalid JSON errors', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('secret-token invalid json'));
|
||||
const error = fetchSimulatorPreviewJsonAsync(preview, '/logs');
|
||||
await expect(error).rejects.toThrow('Could not read the simulator preview API response.');
|
||||
await expect(error).rejects.not.toThrow('secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe(streamSimulatorPreviewAsync, () => {
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
function getRequestSignal(): AbortSignal | undefined {
|
||||
return jest.mocked(fetch).mock.calls[0][1]?.signal ?? undefined;
|
||||
}
|
||||
|
||||
it('parses SSE across chunk boundaries and reports the server closing the stream', async () => {
|
||||
const body = Readable.from([
|
||||
': heartbeat\r\ndata: fir',
|
||||
'st\r\ndata: second\r\n\r\ndata: last\n\n',
|
||||
]);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
const onData = jest.fn();
|
||||
await expect(streamSimulatorPreviewAsync(preview, '/logs', onData, { signal })).rejects.toThrow(
|
||||
'The simulator preview stream ended unexpectedly.'
|
||||
);
|
||||
expect(onData.mock.calls).toEqual([['first\nsecond'], ['last']]);
|
||||
expect(getRequestSignal()?.aborted).toBe(true);
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.redirect).toBe('error');
|
||||
const headers = new Headers(jest.mocked(fetch).mock.calls[0][1]?.headers);
|
||||
expect(headers.get('accept')).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('preserves setup errors when a follow request is refused', async () => {
|
||||
rejectWithStatus(403);
|
||||
await expect(
|
||||
streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal })
|
||||
).rejects.toThrow('refused access');
|
||||
});
|
||||
|
||||
it('stops when the signal aborts and releases the response', async () => {
|
||||
const body = new PassThrough();
|
||||
jest.mocked(fetch).mockImplementationOnce(async (_url, init) => {
|
||||
init?.signal?.addEventListener('abort', () => body.destroy(new Error('aborted')));
|
||||
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const onData = jest.fn(() => {
|
||||
abortController.abort();
|
||||
});
|
||||
const following = streamSimulatorPreviewAsync(preview, '/logs', onData, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
body.write('data: marker\n\n');
|
||||
await following;
|
||||
expect(onData).toHaveBeenCalledWith('marker');
|
||||
expect(body.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('stops when the signal aborts while waiting for response headers', async () => {
|
||||
const abortController = new AbortController();
|
||||
jest.mocked(fetch).mockImplementationOnce(
|
||||
async (_url, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new Error('aborted'));
|
||||
});
|
||||
process.nextTick(() => {
|
||||
abortController.abort();
|
||||
});
|
||||
})
|
||||
);
|
||||
const onData = jest.fn();
|
||||
await streamSimulatorPreviewAsync(preview, '/logs', onData, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a stream that fails while reading without echoing its data', async () => {
|
||||
const body = new PassThrough();
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(body, { headers: { 'content-type': 'text/event-stream' } })
|
||||
);
|
||||
const following = streamSimulatorPreviewAsync(preview, '/logs', jest.fn(), { signal });
|
||||
body.destroy(new Error('secret-token'));
|
||||
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
|
||||
await expect(following).rejects.not.toThrow('secret-token');
|
||||
});
|
||||
|
||||
it('rethrows handler errors unchanged and closes the connection', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response(Readable.from(['data: frame\n\n']), {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
streamSimulatorPreviewAsync(
|
||||
preview,
|
||||
'/logs',
|
||||
() => {
|
||||
throw new Error('Handler failed.');
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
).rejects.toThrow('Handler failed.');
|
||||
expect(getRequestSignal()?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a stream that goes silent after an event once the idle timeout passes', async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
response.write(':\n\ndata: marker\n\n');
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const remote = {
|
||||
...preview,
|
||||
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
|
||||
};
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
|
||||
);
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
try {
|
||||
let received: () => void = () => {};
|
||||
const receivedMarker = new Promise<void>(resolve => {
|
||||
received = resolve;
|
||||
});
|
||||
const following = streamSimulatorPreviewAsync(remote, '/logs', received, { signal });
|
||||
await receivedMarker;
|
||||
jest.advanceTimersByTime(60_000);
|
||||
await expect(following).rejects.toThrow('The simulator preview stream ended unexpectedly.');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the upstream connection after rejecting a response that is not an event stream', async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.write('body that never ends');
|
||||
});
|
||||
const closed = new Promise<void>(resolve => {
|
||||
server.once('connection', socket => socket.once('close', resolve));
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const remote = {
|
||||
...preview,
|
||||
baseUrl: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`),
|
||||
};
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual<typeof import('../../fetch')>('../../fetch').default
|
||||
);
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await expect(
|
||||
streamSimulatorPreviewAsync(remote, '/logs', jest.fn(), { signal })
|
||||
).rejects.toThrow('does not support streaming');
|
||||
await Promise.race([
|
||||
closed,
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('The upstream connection stayed open.'));
|
||||
}, 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
server.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getPreviewApiAccess,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
stripTerminalControlCharacters,
|
||||
} from '../utils';
|
||||
|
||||
const agentDeviceConfig = {
|
||||
@@ -966,3 +967,19 @@ describe('embed instructions', () => {
|
||||
expect(formatRemoteSessionInstructions(config, 'dotenv')).not.toContain('tok-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe(stripTerminalControlCharacters, () => {
|
||||
it('removes terminal escapes and all control characters by default', () => {
|
||||
expect(stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld\u009b')).toBe(
|
||||
'helloworld'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps newlines and tabs when requested', () => {
|
||||
expect(
|
||||
stripTerminalControlCharacters('\u001b[31mhello\u001b[0m\u0007\n\tworld', {
|
||||
keepNewlinesAndTabs: true,
|
||||
})
|
||||
).toBe('hello\n\tworld');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { downloadSimulatorFileAsync } from './download';
|
||||
import { stripTerminalControlCharacters } from './utils';
|
||||
import fetch from '../fetch';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../graphql/generated';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { promptAsync, selectAsync } from '../prompts';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
export type SimulatorSession = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
export function hasSimulatorArtifacts(session: SimulatorSession): boolean {
|
||||
return (
|
||||
session.status === DeviceRunSessionStatus.Stopped ||
|
||||
session.status === DeviceRunSessionStatus.Errored
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadSimulatorArtifactAsync(
|
||||
session: SimulatorSession,
|
||||
type: string,
|
||||
{
|
||||
artifact: index,
|
||||
output,
|
||||
nonInteractive,
|
||||
json,
|
||||
}: { artifact?: number; output?: string; nonInteractive: boolean; json: boolean }
|
||||
): Promise<void> {
|
||||
const artifacts = session.artifacts
|
||||
.filter(artifact => artifact.metadata?.__eas_type === type)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.createdAt) - Date.parse(b.createdAt) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id)
|
||||
);
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
'No matching artifacts are available for this session. Artifacts may still be uploading or may not have been recorded. Run `eas simulator:get` to check the session, then try again.'
|
||||
);
|
||||
}
|
||||
const titles = artifacts.map((artifact, i) => {
|
||||
const metadata = artifact.metadata;
|
||||
const device = metadata?.deviceName ?? metadata?.udid;
|
||||
const part = metadata?.part;
|
||||
return stripTerminalControlCharacters(
|
||||
`${i + 1}. ${artifact.name} — ${artifact.filename} (${formatBytes(artifact.fileSizeBytes ?? 0)})${device ? ` (${device})` : ''}${part !== undefined ? ` (part ${part})` : ''}`
|
||||
);
|
||||
});
|
||||
const selectionMessage = `Choose an artifact with --artifact <index>:\n${titles.join('\n')}`;
|
||||
let selectedIndex = index;
|
||||
if (
|
||||
selectedIndex !== undefined &&
|
||||
(!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > artifacts.length)
|
||||
) {
|
||||
throw new Error(`The artifact index is out of range. ${selectionMessage}`);
|
||||
}
|
||||
if (selectedIndex === undefined) {
|
||||
if (artifacts.length === 1) {
|
||||
selectedIndex = 1;
|
||||
} else if (nonInteractive) {
|
||||
throw new Error(selectionMessage);
|
||||
} else {
|
||||
selectedIndex = await selectAsync(
|
||||
'Select an artifact to download:',
|
||||
titles.map((title, i) => ({ title, value: i + 1 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
const artifact = artifacts[selectedIndex - 1];
|
||||
if (!output) {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Pass --output <path> to save the session artifact in non-interactive mode.');
|
||||
}
|
||||
const { filePath } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'filePath',
|
||||
message: 'Where should the artifact be saved?',
|
||||
initial: stripTerminalControlCharacters(path.basename(artifact.filename)),
|
||||
validate: value => value.trim().length > 0 || 'Enter an output path.',
|
||||
});
|
||||
output = typeof filePath === 'string' ? filePath : undefined;
|
||||
}
|
||||
if (!output) {
|
||||
throw new Error('Enter an output path.');
|
||||
}
|
||||
const spinner = json ? undefined : ora('Downloading session artifact').start();
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = await downloadSimulatorFileAsync(output, async signal => {
|
||||
try {
|
||||
return await fetch(artifact.downloadUrl, { signal, timeout: 30_000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not download the session artifact. Check your internet connection and try again.'
|
||||
);
|
||||
}
|
||||
});
|
||||
spinner?.succeed();
|
||||
} catch (error) {
|
||||
spinner?.fail();
|
||||
throw error;
|
||||
}
|
||||
if (json) {
|
||||
const { id, name, filename, metadata } = artifact;
|
||||
printJsonOnlyOutput({
|
||||
deviceRunSessionId: session.id,
|
||||
artifact: { id, name, filename, metadata },
|
||||
filePath,
|
||||
});
|
||||
} else {
|
||||
Log.log(
|
||||
`Saved ${stripTerminalControlCharacters(artifact.name)} to ${stripTerminalControlCharacters(filePath)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs-extra';
|
||||
import { open } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { type Response } from '../fetch';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
export async function downloadSimulatorFileAsync(
|
||||
output: string,
|
||||
getResponse: (signal: AbortSignal) => Promise<Response>
|
||||
): Promise<string> {
|
||||
const outputPath = path.resolve(output);
|
||||
if (await fs.pathExists(outputPath)) {
|
||||
throw new Error(
|
||||
'The output file already exists. The command does not overwrite files. Choose another --output path.'
|
||||
);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]);
|
||||
const interruptHandler = (): void => {
|
||||
controller.abort();
|
||||
};
|
||||
let response: Response | undefined;
|
||||
let created = false;
|
||||
process.on('SIGINT', interruptHandler);
|
||||
try {
|
||||
response = await getResponse(signal);
|
||||
const file = await open(outputPath, 'wx', 0o600);
|
||||
created = true;
|
||||
try {
|
||||
await pipeline(response.body, file.createWriteStream(), { signal });
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
if (created) {
|
||||
await fs.remove(outputPath);
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new Error(
|
||||
'The output file already exists. The command does not overwrite files. Choose another --output path.'
|
||||
);
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('The download was canceled.');
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error(
|
||||
'The download timed out. It did not finish within 10 minutes. Check your internet connection, then run the command again.'
|
||||
);
|
||||
}
|
||||
if (!response) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
'Could not save the download. Check that the --output directory exists and is writable, then try again.'
|
||||
);
|
||||
} finally {
|
||||
controller.abort();
|
||||
process.removeListener('SIGINT', interruptHandler);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import { type DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '@expo/eas-build-job';
|
||||
|
||||
import { stripTerminalControlCharacters } from './utils';
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import { formatMilliseconds } from '../utils/timer';
|
||||
|
||||
@@ -109,12 +108,3 @@ function getScreenshotFilename(event: DeviceRunSessionEvent): string | undefined
|
||||
}
|
||||
return event.data.path.split(/[\\/]/).at(-1);
|
||||
}
|
||||
|
||||
function stripTerminalControlCharacters(value: string): string {
|
||||
return [...stripVTControlCharacters(value)]
|
||||
.filter(character => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint >= 0x20 && !(codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { getPreviewApiAccess } from './utils';
|
||||
import { type SimulatorSession } from './artifacts';
|
||||
import fetch, { RequestError, type Response } from '../fetch';
|
||||
import { DeviceRunSessionStatus } from '../graphql/generated';
|
||||
|
||||
const PREVIEW_API_TIMEOUT_MS = 30_000;
|
||||
// The preview server sends a heartbeat every 15 seconds, so a longer silence means the stream is gone.
|
||||
const PREVIEW_STREAM_IDLE_TIMEOUT_MS = 60_000;
|
||||
const INVALID_PREVIEW_API_URL_MESSAGE =
|
||||
'The simulator session has an invalid preview API URL. The session reported a URL that is not a valid HTTP(S) URL. Start a new session with `eas simulator:start`.';
|
||||
const PREVIEW_STREAM_ENDED_MESSAGE =
|
||||
"The simulator preview stream ended unexpectedly. The connection to the session's preview server closed. Run `eas simulator:get` to check that the session is still running, then run the command again.";
|
||||
const PREVIEW_DATA_NOT_FOUND_MESSAGE =
|
||||
"The requested preview data was not found. The session's preview server does not support this request. Start a new session with `eas simulator:start`, then try again.";
|
||||
|
||||
export type SimulatorPreview = {
|
||||
deviceRunSessionId: string;
|
||||
baseUrl: URL;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type SimulatorPreviewRequestOptions = {
|
||||
query?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
/** Message for a 404 response. Defaults to a generic message for unsupported preview data. */
|
||||
notFoundMessage?: string;
|
||||
};
|
||||
|
||||
export async function resolveSimulatorPreviewAsync(
|
||||
session: SimulatorSession
|
||||
): Promise<SimulatorPreview> {
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
'The simulator session is not running. Live preview data is only available while a session runs. Start a new session with `eas simulator:start`.'
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
'This simulator session does not expose a preview API. The command reads live data through the preview API, and the session did not report its URL and token. Start a new iOS session with `eas simulator:start --platform ios`.'
|
||||
);
|
||||
}
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(access.apiUrl);
|
||||
} catch {
|
||||
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
|
||||
}
|
||||
if (!['https:', 'http:'].includes(baseUrl.protocol)) {
|
||||
throw new Error(INVALID_PREVIEW_API_URL_MESSAGE);
|
||||
}
|
||||
// Requests send the token in a header, so keep it out of URLs that can appear in errors.
|
||||
baseUrl.searchParams.delete('token');
|
||||
baseUrl.hash = '';
|
||||
return { deviceRunSessionId: session.id, baseUrl, token: access.token };
|
||||
}
|
||||
|
||||
export async function fetchSimulatorPreviewAsync(
|
||||
preview: SimulatorPreview,
|
||||
route: string,
|
||||
{
|
||||
query,
|
||||
signal,
|
||||
notFoundMessage = PREVIEW_DATA_NOT_FOUND_MESSAGE,
|
||||
accept,
|
||||
}: SimulatorPreviewRequestOptions & { accept?: string } = {}
|
||||
): Promise<Response> {
|
||||
const url = new URL(route, preview.baseUrl);
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
// Aborting closes the connection of an error response, whose body is never read.
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
return await fetch(url.toString(), {
|
||||
timeout: PREVIEW_API_TIMEOUT_MS,
|
||||
redirect: 'error',
|
||||
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${preview.token}`,
|
||||
...(accept ? { Accept: accept } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof RequestError) {
|
||||
controller.abort();
|
||||
if (error.response.headers.get('ngrok-error-code') === 'ERR_NGROK_3200') {
|
||||
throw new Error(
|
||||
'The simulator preview is offline. The session may have stopped or reached its time limit. Start a new session with `eas simulator:start`.'
|
||||
);
|
||||
}
|
||||
const { status } = error.response;
|
||||
if (status === 401 || status === 403) {
|
||||
throw new Error(
|
||||
"The preview API refused access. It did not accept the session's preview token. Run `eas simulator:get` to check that the session is still running, then try again."
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
throw new Error(notFoundMessage);
|
||||
}
|
||||
throw new Error(
|
||||
`The preview API request failed (HTTP ${status}). The session's preview server returned an error. Run \`eas simulator:get\` to check that the session is still running, then try again.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Could not connect to the simulator preview API. The request did not reach the session's preview server. Check your internet connection. Run `eas simulator:get` to check that the session is still running."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSimulatorPreviewJsonAsync<T>(
|
||||
preview: SimulatorPreview,
|
||||
route: string,
|
||||
options: Omit<SimulatorPreviewRequestOptions, 'signal'> = {}
|
||||
): Promise<T> {
|
||||
const response = await fetchSimulatorPreviewAsync(preview, route, {
|
||||
...options,
|
||||
accept: 'application/json',
|
||||
});
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not read the simulator preview API response. The response was incomplete or not valid JSON. Try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onData` with each server-sent event's data until `signal` aborts. The stream ending is an error.
|
||||
* Errors thrown by `onData` stop the stream and are rethrown unchanged.
|
||||
*/
|
||||
export async function streamSimulatorPreviewAsync(
|
||||
preview: SimulatorPreview,
|
||||
route: string,
|
||||
onData: (data: string) => void,
|
||||
{ signal, ...options }: SimulatorPreviewRequestOptions & { signal: AbortSignal }
|
||||
): Promise<void> {
|
||||
// Aborting closes the connection, including when the stream stops because of an error.
|
||||
const controller = new AbortController();
|
||||
let reading = false;
|
||||
let onDataError: unknown;
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
const resetIdleTimer = (): void => {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, PREVIEW_STREAM_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
try {
|
||||
const response = await fetchSimulatorPreviewAsync(preview, route, {
|
||||
...options,
|
||||
signal: AbortSignal.any([signal, controller.signal]),
|
||||
accept: 'text/event-stream',
|
||||
});
|
||||
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
throw new Error(
|
||||
'This simulator session does not support streaming. Its preview server did not return an event stream. Run the command without --follow.'
|
||||
);
|
||||
}
|
||||
reading = true;
|
||||
resetIdleTimer();
|
||||
let data: string[] = [];
|
||||
for await (const line of readline.createInterface({
|
||||
input: response.body,
|
||||
crlfDelay: Infinity,
|
||||
})) {
|
||||
resetIdleTimer();
|
||||
if (line === '' && data.length > 0) {
|
||||
const frame = data.join('\n');
|
||||
data = [];
|
||||
try {
|
||||
onData(frame);
|
||||
} catch (error) {
|
||||
onDataError = error;
|
||||
throw error;
|
||||
}
|
||||
} else if (line.startsWith('data:')) {
|
||||
data.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
}
|
||||
// The server keeps the stream open while the session runs, so a clean end is unexpected too.
|
||||
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (!reading || error === onDataError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(PREVIEW_STREAM_ENDED_MESSAGE);
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import chalk from 'chalk';
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import {
|
||||
AppPlatform,
|
||||
@@ -655,3 +656,54 @@ function formatControllerInstructions(
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Session text is untrusted, so strip terminal escape and control characters before printing it.
|
||||
export function stripTerminalControlCharacters(
|
||||
value: string,
|
||||
{ keepNewlinesAndTabs = false }: { keepNewlinesAndTabs?: boolean } = {}
|
||||
): string {
|
||||
return stripVTControlCharacters(value).replace(/\p{Cc}/gu, character =>
|
||||
keepNewlinesAndTabs && (character === '\n' || character === '\t') ? character : ''
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidLogTime(at: unknown): at is number {
|
||||
return typeof at === 'number' && !Number.isNaN(new Date(at).getTime());
|
||||
}
|
||||
|
||||
export function formatLogLine(raw: string, timestamp = false, at = Date.now()): string {
|
||||
const time = new Date(isValidLogTime(at) ? at : Date.now()).toISOString();
|
||||
try {
|
||||
const entry = JSON.parse(raw) as {
|
||||
timestamp?: string;
|
||||
processImagePath?: string;
|
||||
processID?: number;
|
||||
eventMessage?: string;
|
||||
messageType?: string | number;
|
||||
};
|
||||
if (typeof entry.eventMessage === 'string') {
|
||||
const prefix = timestamp
|
||||
? `${chalk.dim(stripTerminalControlCharacters(entry.timestamp ?? time))} `
|
||||
: '';
|
||||
const processName = entry.processImagePath?.split('/').at(-1) ?? 'unknown';
|
||||
const processId = typeof entry.processID === 'number' ? `:${entry.processID}` : '';
|
||||
const processLabel = chalk.cyan(
|
||||
stripTerminalControlCharacters(`[${processName}${processId}]`)
|
||||
);
|
||||
const level = String(entry.messageType ?? '').toLowerCase();
|
||||
let message = stripTerminalControlCharacters(entry.eventMessage, {
|
||||
keepNewlinesAndTabs: true,
|
||||
});
|
||||
if (level === 'error' || level === '16') {
|
||||
message = chalk.red(message);
|
||||
} else if (level === 'fault' || level === '17') {
|
||||
message = chalk.red.bold(message);
|
||||
} else if (level === 'debug' || level === '2') {
|
||||
message = chalk.dim(message);
|
||||
}
|
||||
return `${prefix}${processLabel} ${message}`;
|
||||
}
|
||||
} catch {}
|
||||
const prefix = timestamp ? `${chalk.dim(time)} ` : '';
|
||||
return `${prefix}${stripTerminalControlCharacters(raw, { keepNewlinesAndTabs: true })}`;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.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": "27.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "26.0.0",
|
||||
"@expo/build-tools": "27.1.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "26.0.0",
|
||||
"version": "27.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "26.0.0",
|
||||
"@expo/build-tools": "27.1.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "26.0.0",
|
||||
"@expo/logger": "26.0.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "26.0.0",
|
||||
"@expo/template-file": "26.0.0",
|
||||
"@expo/turtle-spawn": "26.0.0",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -359,6 +359,56 @@ describe('prepareRuntimeEnvironment', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installPnpm', () => {
|
||||
const pnpmCtx = { ...ctx, metadata: { requiredPackageManager: 'pnpm' } };
|
||||
|
||||
function mockPnpmReinstallFailure(stderr: string): void {
|
||||
let hasProbedPreinstalledPnpm = false;
|
||||
jest.mocked(spawn).mockImplementation((cmd, args) => {
|
||||
if (cmd === 'pnpm' && args[0] === '--version') {
|
||||
if (!hasProbedPreinstalledPnpm) {
|
||||
hasProbedPreinstalledPnpm = true;
|
||||
return Promise.resolve(spawnResult);
|
||||
}
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('pnpm --version exited with non-zero code: 1'), {
|
||||
stdout: '',
|
||||
stderr,
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(spawnResult);
|
||||
});
|
||||
}
|
||||
|
||||
it('throws the pnpm Node.js version error instead of the generic install error when the reinstalled pnpm cannot run on the build Node.js', async () => {
|
||||
mockPnpmReinstallFailure(
|
||||
[
|
||||
'warn: This version of pnpm requires at least Node.js v22.13',
|
||||
'warn: The current version of Node.js is v20.18.0',
|
||||
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false)
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
|
||||
message: expect.stringContaining(
|
||||
'requires Node.js 22.13.0 or newer, but runs Node.js 20.18.0'
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a pnpm install failure without the Node.js banner as a generic install error', async () => {
|
||||
mockPnpmReinstallFailure('pnpm: command not found');
|
||||
|
||||
await expect(
|
||||
prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false)
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_BUILD_SYSTEM_DEPS_INSTALL_ERROR' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BuildContext, RuntimeSettings } from '@expo/build-tools';
|
||||
import {
|
||||
BuildContext,
|
||||
RuntimeSettings,
|
||||
detectPnpmUnsupportedNodeVersionError,
|
||||
} from '@expo/build-tools';
|
||||
import { Job, errors } from '@expo/eas-build-job';
|
||||
import templateFile from '@expo/template-file';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -276,7 +280,8 @@ async function installPnpm({
|
||||
} catch (err: any) {
|
||||
ctx.logger.error({ err }, `Failed to install pnpm ${versionToInstall}\n`);
|
||||
if (ctx.metadata?.requiredPackageManager === 'pnpm') {
|
||||
throw new SystemDepsInstallError('pnpm');
|
||||
const output = [err.stdout, err.stderr].filter(Boolean).join('\n');
|
||||
throw detectPnpmUnsupportedNodeVersionError(output) ?? new SystemDepsInstallError('pnpm');
|
||||
} else {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
ctx.logger.warn(
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user