mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e1b01bd7 | ||
|
|
9adcdfab1c | ||
|
|
444c926bb8 | ||
|
|
3e73b077ce | ||
|
|
3350e5c6f3 | ||
|
|
3ffbbb2f45 | ||
|
|
4e453e5d9c | ||
|
|
4f7ba2d5a3 | ||
|
|
f65472ae0a | ||
|
|
40f2b55f96 | ||
|
|
dc759c0854 | ||
|
|
e38a605d89 | ||
|
|
a586f0cb68 | ||
|
|
eebfa728a7 | ||
|
|
bf478c719c | ||
|
|
a7fcb5140c | ||
|
|
f0a7377069 | ||
|
|
b755469716 | ||
|
|
1830f88603 | ||
|
|
75f83829d8 | ||
|
|
08c92bf937 | ||
|
|
1615d36cf9 | ||
|
|
88e23aaa09 | ||
|
|
0a274a6fef | ||
|
|
78f6782970 | ||
|
|
5757091b33 | ||
|
|
dd6ad17179 | ||
|
|
695f567760 | ||
|
|
15e2302575 | ||
|
|
ee4f03d44e |
No files matched your search
@@ -19,7 +19,9 @@
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function"
|
||||
"create-eas-build-function",
|
||||
"@expo/vm-tools",
|
||||
"@expo/simulator-launcher"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [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
|
||||
@@ -26,6 +74,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [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))
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "26.0.0",
|
||||
"PRODUCTION": "26.0.0"
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,18 +42,18 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.0.0",
|
||||
"@expo/eas-build-job": "27.0.0",
|
||||
"@expo/downloader": "27.2.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.0.0",
|
||||
"@expo/logger": "27.2.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": "27.0.0",
|
||||
"@expo/template-file": "27.0.0",
|
||||
"@expo/turtle-spawn": "27.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/template-file": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
@@ -59,6 +60,47 @@ describe(XcodeBuildLogger, () => {
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
|
||||
const { ExpoRunFormatter: RealFormatter } =
|
||||
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
|
||||
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
|
||||
|
||||
let rejectReader!: (error: Error) => void;
|
||||
const spawnPromise = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectReader = reject;
|
||||
}),
|
||||
{
|
||||
child: {
|
||||
stdout,
|
||||
kill: () =>
|
||||
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
|
||||
},
|
||||
}
|
||||
);
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
const diagnostics =
|
||||
'/project/First.swift:12:3: error: first compiler error\n' +
|
||||
' /project/Last.swift:20:4: error: final compiler error\n';
|
||||
vol.fromJSON({ '/logs/build.log': diagnostics });
|
||||
const logger = createMockLogger();
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
stdout.emit('data', diagnostics);
|
||||
|
||||
await buildLogger.flush();
|
||||
|
||||
const output = jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
expect(output).toContain('first compiler error');
|
||||
expect(output).toContain('final compiler error');
|
||||
expect(output).toContain('Last.swift:20:4');
|
||||
expect(output).toContain('2 error(s)');
|
||||
expect(output.match(/final compiler error/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -80,9 +80,12 @@ export class XcodeBuildLogger {
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} finally {
|
||||
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -382,6 +382,29 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('passes the existing Android selection to the preview host', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await runSessionAsync(logger);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ previewDeviceId: 'emulator-5554' })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the selected iOS preview target without enabling Simulator startup', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid }, { udid: 'BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB' }] as Awaited<
|
||||
ReturnType<typeof IosSimulatorUtils.getAvailableDevicesAsync>
|
||||
>);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN);
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options).toHaveProperty('previewDeviceId', udid);
|
||||
expect(options.iosSimulatorUdid).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
|
||||
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toContain('HTTP 404');
|
||||
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
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,
|
||||
isMissingAppIconsError,
|
||||
isMissingBackgroundTaskIdentifiersError,
|
||||
isMissingPurposeStringError,
|
||||
isRedundantBinaryUploadError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
@@ -45,6 +55,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);
|
||||
@@ -73,6 +97,41 @@ describe(isSdkVersionIssueError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingBackgroundTaskIdentifiersError, () => {
|
||||
it('returns true when all errors are missing BG task identifiers codes', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingAppIconsError, () => {
|
||||
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
|
||||
'recognizes missing-icon validation codes %j',
|
||||
(...codes) => {
|
||||
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingAppIconsError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
@@ -110,3 +169,176 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90771',
|
||||
description:
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
|
||||
},
|
||||
],
|
||||
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_MISSING_BG_TASK_IDENTIFIERS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
|
||||
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_MISSING_APP_ICONS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing CFBundleIconName in Watch.app. (90713)'
|
||||
);
|
||||
} 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
@@ -276,8 +277,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
|
||||
@@ -262,6 +262,45 @@ 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 (isMissingBackgroundTaskIdentifiersError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
|
||||
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
|
||||
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
|
||||
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingAppIconsError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
|
||||
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
|
||||
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
|
||||
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
@@ -344,6 +383,21 @@ 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 isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90771');
|
||||
}
|
||||
|
||||
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
@@ -507,14 +507,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
|
||||
let streamOutput = '';
|
||||
stream?.on('data', (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
output = `${output}${text}`.slice(-16_384);
|
||||
streamOutput = `${streamOutput}${text}`;
|
||||
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
|
||||
started = true;
|
||||
}
|
||||
streamOutput = streamOutput.slice(-16_384);
|
||||
});
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
captureStreamOutput(recordingProcess.stdout);
|
||||
captureStreamOutput(recordingProcess.stderr);
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -144,7 +144,10 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
child.stdout?.emit('data', 'Downloading');
|
||||
stderr.write('serve-sim:recording-');
|
||||
child.stdout?.emit('data', ' package');
|
||||
stderr.write('started\r\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
@@ -362,6 +365,32 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not mistake bundled source in an error for a recording start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit(
|
||||
'data',
|
||||
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
|
||||
);
|
||||
recorder.fail(new Error('recorder exited before connecting'));
|
||||
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
try {
|
||||
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
|
||||
} finally {
|
||||
currentTime += 311_000;
|
||||
await finishing;
|
||||
}
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
|
||||
@@ -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]',
|
||||
|
||||
+43
@@ -7,6 +7,7 @@ import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
@@ -266,6 +267,48 @@ describe('with a device run session token', () => {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles a stopped-session TURN response without a warning or retry', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'Stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session was stopped before TURN credentials were fetched.'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])(
|
||||
'still warns on a TURN failure with code %s',
|
||||
async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
@@ -68,6 +69,7 @@ jest.mock('../remoteDeviceRunSession', () => ({
|
||||
jest.mock('../deviceRunSessionPreview', () => ({
|
||||
...jest.requireActual('../deviceRunSessionPreview'),
|
||||
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
|
||||
captureDeviceRunSessionPreviewAsync: jest.fn(),
|
||||
startDeviceRunSessionPreview: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
@@ -191,6 +193,91 @@ afterEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, '', 'invalid serial', 'all', 'booted'])(
|
||||
'starts Hub without adding a device restriction for %j',
|
||||
async previewDeviceId => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].args).not.toContain('--single-device');
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the supplied Android serial for Hub and thumbnails without selecting another device', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'no-device-id' }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'emulator-5556',
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', 'emulator-5556']);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await jest
|
||||
.mocked(startDeviceRunSessionPreview)
|
||||
.mock.calls[0][0].captureAsync(new AbortController().signal);
|
||||
expect(captureDeviceRunSessionPreviewAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ device: 'emulator-5556' })
|
||||
);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins an already-booted iOS preview without taking over boot or setup', async () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: udid }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: udid,
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', udid]);
|
||||
expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled();
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects readiness on a different Simulator from the selected preview target', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA',
|
||||
})
|
||||
).rejects.toThrow('but this session requested AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA');
|
||||
expect(stopServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('targets the selected Simulator in the serve-sim invocation', () => {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: 'session-udid' }).slice(0, 4)).toEqual([
|
||||
'@expo/serve-sim@latest',
|
||||
|
||||
@@ -530,6 +530,22 @@ describe(describeServeSimLaunch, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only pins hosts when a valid device ID is supplied', () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: udid })).toEqual(
|
||||
expect.arrayContaining(['--single-device', udid])
|
||||
);
|
||||
for (const iosSimulatorUdid of [undefined, '', 'booted', 'iPhone 17']) {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid })).not.toContain('--single-device');
|
||||
}
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device: 'emulator-5554' })).toEqual(
|
||||
expect.arrayContaining(['--single-device', 'emulator-5554'])
|
||||
);
|
||||
for (const device of [undefined, '', 'all', 'booted', 'invalid serial']) {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device })).not.toContain('--single-device');
|
||||
}
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -144,6 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const attempts: unknown[] = [];
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
@@ -161,6 +162,7 @@ export namespace GooglePlayUtils {
|
||||
throw new SystemError('Google did not confirm the committed edit.');
|
||||
}
|
||||
} catch (error) {
|
||||
attempts.push(...(error instanceof AggregateError ? error.errors : [error]));
|
||||
if (error instanceof GooglePlayAuthRequestError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -168,8 +170,14 @@ export namespace GooglePlayUtils {
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
const attemptMessages = attempts
|
||||
.map(
|
||||
(attempt, index) =>
|
||||
`${index + 1}. ${attempt instanceof Error ? attempt.message : String(attempt)}`
|
||||
)
|
||||
.join('\n');
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.\n\nCommit attempts:\n${attemptMessages}`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,6 +84,41 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('shows failed commit attempts in order across processing, HTTP, and network retries', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.replyWithError(Object.assign(new Error('Lost commit response'), { code: 'ECONNRESET' }));
|
||||
const message = 'You must let us know whether your app includes any health features.';
|
||||
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
expect((mapped as Error).message).toContain(`Google Play request failed (HTTP 403): ${message}`);
|
||||
expect((mapped as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect(mapped).toMatchObject({
|
||||
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
|
||||
cause: expect.any(AggregateError),
|
||||
});
|
||||
const attempts = (mapped as Error).message.split('Commit attempts:\n')[1]?.split('\n');
|
||||
expect(attempts).toEqual([
|
||||
`1. Google Play request failed (HTTP 400): ${pendingUploadMessage}`,
|
||||
'2. Google Play request failed (HTTP 503).',
|
||||
'3. Google Play request failed before a response was received.',
|
||||
`4. Google Play request failed (HTTP 403): ${message}`,
|
||||
]);
|
||||
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(3);
|
||||
expect(((mapped as Error).cause as AggregateError).errors[1]).toMatchObject({
|
||||
cause: { code: 'ECONNRESET' },
|
||||
});
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled before commit');
|
||||
@@ -250,7 +285,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
const error = await commit().catch(error => error);
|
||||
expect(error.message).toContain('Google Play request failed (HTTP 503): Backend error');
|
||||
expect(error).toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
|
||||
@@ -55,6 +55,8 @@ import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulat
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const IOS_SIMULATOR_UDID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
const ANDROID_DEVICE_SERIAL = /^(?!all$|booted$)[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
@@ -157,6 +159,9 @@ export function createServeSimArgs({
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
|
||||
...(iosSimulatorUdid && IOS_SIMULATOR_UDID.test(iosSimulatorUdid)
|
||||
? ['--single-device', iosSimulatorUdid]
|
||||
: []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -198,6 +203,7 @@ export function createServeSimArgs({
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
device,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
@@ -206,6 +212,7 @@ export function createExpoDeviceHubArgs({
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
device?: string;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
@@ -215,6 +222,7 @@ export function createExpoDeviceHubArgs({
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
...(device && ANDROID_DEVICE_SERIAL.test(device) ? ['--single-device', device] : []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -387,6 +395,8 @@ type DeviceSessionHostOptions = {
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
/** Selected preview target; does not enable Simulator boot or setup. */
|
||||
previewDeviceId?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
@@ -431,6 +441,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
signal,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
previewDeviceId,
|
||||
installAppPath,
|
||||
application,
|
||||
onStartupError,
|
||||
@@ -479,6 +490,10 @@ async function startDeviceSessionHostInternalAsync(
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const deviceIdPattern = isAndroid ? ANDROID_DEVICE_SERIAL : IOS_SIMULATOR_UDID;
|
||||
const validPreviewDevice =
|
||||
previewDeviceId && deviceIdPattern.test(previewDeviceId) ? previewDeviceId : undefined;
|
||||
const previewDevice = isAndroid ? validPreviewDevice : (iosSimulatorUdid ?? validPreviewDevice);
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
@@ -506,6 +521,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
@@ -517,7 +533,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
@@ -762,9 +778,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = (await readiness).enforceValue();
|
||||
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
|
||||
if (!isAndroid && previewDevice && device.toLowerCase() !== previewDevice.toLowerCase()) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
|
||||
`serve-sim became ready on ${device}, but this session requested ${previewDevice}.`
|
||||
);
|
||||
}
|
||||
hostReady = true;
|
||||
@@ -807,7 +823,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
captureAsync: signal =>
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
device: previewDevice ?? device,
|
||||
env,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import * as jose from 'jose';
|
||||
@@ -617,6 +617,24 @@ export class AscApiClient {
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
|
||||
if (response.status === 401 && ascError.code === 'NOT_AUTHORIZED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_INVALID_AUTH',
|
||||
'App Store Connect rejected the API authentication credentials. ' +
|
||||
'Check that the API key is active and belongs to the correct App Store Connect account. ' +
|
||||
'If the key is stored in EAS credentials, replace or reconfigure it. ' +
|
||||
'For a local key, check `key_id` and `key`, and `issuer_id` for a team key. Then try again.',
|
||||
{ cause: ascError, docsUrl: 'https://docs.expo.dev/submit/ios/' }
|
||||
);
|
||||
}
|
||||
if (response.status === 500 && ascError.code === 'UNEXPECTED_ERROR') {
|
||||
return new SystemError(
|
||||
'App Store Connect returned an unexpected server error. ' +
|
||||
'Check Apple’s service status at https://developer.apple.com/system-status/ and verify whether the requested operation completed in App Store Connect before trying again. ' +
|
||||
'If the error continues, try again later or contact Apple Developer Support.',
|
||||
{ cause: ascError, trackingCode: 'EAS_ASC_SERVER_ERROR' }
|
||||
);
|
||||
}
|
||||
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
@@ -85,6 +85,57 @@ describe(AscApiClient, () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains NOT_AUTHORIZED without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '401', code: 'NOT_AUTHORIZED' };
|
||||
const other = { status: '401', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(401, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({ errorCode: 'EAS_ASC_INVALID_AUTH', cause: known });
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains UNEXPECTED_ERROR without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '500', code: 'UNEXPECTED_ERROR' };
|
||||
const other = { status: '500', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(500, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({
|
||||
trackingCode: 'EAS_ASC_SERVER_ERROR',
|
||||
cause: known,
|
||||
});
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
|
||||
@@ -391,7 +391,9 @@ describe('commitBuildUploadFileAsync', () => {
|
||||
.reply(status, { errors: [{ code }] });
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).rejects.toMatchObject({ status, code });
|
||||
).rejects.toMatchObject(
|
||||
code === 'NOT_AUTHORIZED' ? { errorCode: 'EAS_ASC_INVALID_AUTH' } : { status, code }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not commit when the initial state read fails', async () => {
|
||||
|
||||
@@ -524,6 +524,14 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
|
||||
@@ -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.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.0.0"
|
||||
"@expo/steps": "27.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.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.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.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.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.0.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.0.0",
|
||||
"version": "27.2.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": "27.0.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.0.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 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
|
||||
|
||||
+224
-149
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": "27.0.0",
|
||||
"version": "27.2.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": "27.0.0",
|
||||
"@expo/eas-json": "27.0.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/eas-json": "27.2.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.0.0",
|
||||
"@expo/logger": "27.2.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": "27.0.0",
|
||||
"@expo/steps": "27.2.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: [],
|
||||
|
||||
@@ -70,6 +70,8 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP = 'build cli free tier near limit agent updates tip',
|
||||
SIMULATORS_LAUNCH_HINT_SHOWN = 'build cli simulators launch hint shown',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
maybeAppendFreeTierLimitAgentGuidanceAsync,
|
||||
resolveFreeTierLimitAgentGuidanceVariantAsync,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-agent-guidance';
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: ACCOUNT_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
|
||||
function findAccountIdsByVariant(): Map<string, string> {
|
||||
const { client } = createClient();
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, {
|
||||
accountId,
|
||||
}),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
describe(getFreeTierLimitAgentGuidanceExperiment, () => {
|
||||
it('returns the handle when the command has an experimentation client', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitAgentGuidanceExperiment({ experimentation, accountId: 'account-id' })
|
||||
).toEqual({ experimentation, accountId: 'account-id' });
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(getFreeTierLimitAgentGuidanceExperiment({ accountId: 'account-id' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitAgentGuidanceVariantAsync, () => {
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('does not load the client when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the client once across repeated reads', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const loadAsync = jest.fn(async () => client);
|
||||
const experiment = {
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
};
|
||||
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
|
||||
expect(loadAsync).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once when a coding agent runs the CLI', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: controlAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeAppendFreeTierLimitAgentGuidanceAsync, () => {
|
||||
const treatmentAccountId = findAccountIdsByVariant().get('treatment')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when the command has no experimentation client', async () => {
|
||||
expect(await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, undefined)).toBe(
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the exposure and appends the guidance in the treatment arm', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.IOS, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe(
|
||||
[
|
||||
'Error 1',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the message unchanged in the control arm', async () => {
|
||||
const client = ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', 'control']])],
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../../graphql/generated';
|
||||
import { UserPreferenceMutation } from '../../graphql/mutations/UserPreferenceMutation';
|
||||
import { AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import { UserPreferenceQuery } from '../../graphql/queries/UserPreferenceQuery';
|
||||
import Log from '../../log';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
|
||||
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
|
||||
FreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
FreeTierNearLimitAgentUpdatesTipPlatformProject,
|
||||
formatFreeTierNearLimitAgentUpdatesTip,
|
||||
getFreeTierBuildUsageAsync,
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey,
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip,
|
||||
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip,
|
||||
isWithinFreeTierNearLimitBand,
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
|
||||
resolveFreeTierNearLimitAgentUpdatesTipVariantAsync,
|
||||
} from '../freeTierNearLimitAgentUpdatesTip';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../../commandUtils/context/contextUtils/createGraphqlClient');
|
||||
jest.mock('../../graphql/queries/AccountQuery');
|
||||
jest.mock('../../graphql/queries/UserPreferenceQuery');
|
||||
jest.mock('../../graphql/mutations/UserPreferenceMutation');
|
||||
jest.mock('../../project/projectUtils', () => ({
|
||||
...jest.requireActual('../../project/projectUtils'),
|
||||
isExpoUpdatesInstalled: jest.fn(),
|
||||
isUsingEASUpdate: jest.fn(),
|
||||
}));
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-near-limit-agent-updates-tip';
|
||||
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id' };
|
||||
const EXP = { name: 'app', slug: 'app' } as ExpoConfig;
|
||||
const IOS_EXP = { name: 'app', slug: 'app', ios: { bundleIdentifier: 'com.app' } } as ExpoConfig;
|
||||
const USER = { __typename: 'User', id: 'user-id' } as Actor;
|
||||
const SSO_USER = { __typename: 'SSOUser', id: 'sso-user-id' } as Actor;
|
||||
const ROBOT = { __typename: 'Robot', id: 'robot-id' } as Actor;
|
||||
const PARTNER = { __typename: 'PartnerActor', id: 'partner-id' } as Actor;
|
||||
const AUTHENTICATION_INFO: LoggedInAuthenticationInfo = {
|
||||
accessToken: 'access-token',
|
||||
sessionSecret: null,
|
||||
};
|
||||
const GRAPHQL_CLIENT = {} as FreeTierNearLimitAgentUpdatesTipExperiment['graphqlClient'];
|
||||
|
||||
const ANDROID_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/android';
|
||||
const IOS_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/ios';
|
||||
|
||||
const ANDROID_TIP_11_OF_15 = [
|
||||
"Tip: this account has used 11 of 15 Android builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.",
|
||||
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
|
||||
'Next: eas update:configure --non-interactive, then build once more.',
|
||||
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
|
||||
'Docs: https://docs.expo.dev/eas-update/getting-started/',
|
||||
].join('\n');
|
||||
|
||||
function accountDefinition(enrolledSegments: number): ExperimentationDefinition {
|
||||
return {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} rollout`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: enrolledSegments,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION = accountDefinition(1000);
|
||||
|
||||
/** Like the production soft launch: 20% of accounts are in the experiment. */
|
||||
const PARTIAL_ROLLOUT_DEFINITION = accountDefinition(200);
|
||||
|
||||
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: definition,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes a throwaway client, so the exposures it logs do not affect the client under test. The
|
||||
* probe uses a null default, so accounts outside the rollout can be told apart from control.
|
||||
*/
|
||||
function findAccountIdsByVariant(
|
||||
definition: ExperimentationDefinition,
|
||||
variants: (string | null)[]
|
||||
): Map<string | null, string> {
|
||||
const { client } = createClient(definition);
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, {
|
||||
accountId,
|
||||
}),
|
||||
'variant',
|
||||
null,
|
||||
variants
|
||||
);
|
||||
}
|
||||
|
||||
function createOverriddenClient(variant: string): ExperimentationClient {
|
||||
return ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function project(
|
||||
overrides: Partial<FreeTierNearLimitAgentUpdatesTipPlatformProject> = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipPlatformProject {
|
||||
return {
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
manifestHostOverride: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createExperiment(
|
||||
accountId: string,
|
||||
{
|
||||
client = createClient().client,
|
||||
runFromCI = false,
|
||||
json = false,
|
||||
isUser = true,
|
||||
analytics = createMockAnalytics(),
|
||||
projectByPlatform = { [AppPlatform.Android]: project(), [AppPlatform.Ios]: project() },
|
||||
}: {
|
||||
client?: ExperimentationClient;
|
||||
runFromCI?: boolean;
|
||||
json?: boolean;
|
||||
isUser?: boolean;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
projectByPlatform?: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'];
|
||||
} = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment {
|
||||
return {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
graphqlClient: GRAPHQL_CLIENT,
|
||||
analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId,
|
||||
isUser,
|
||||
runFromCI,
|
||||
json,
|
||||
projectByPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
function build(platform: AppPlatform, status: BuildStatus = BuildStatus.Finished): BuildFragment {
|
||||
return { id: `${platform}-build`, platform, status } as BuildFragment;
|
||||
}
|
||||
|
||||
type PlatformUsage = { value: number; limit: number };
|
||||
|
||||
function usageResponse({
|
||||
subscriptionName = 'Free' as string | null,
|
||||
android = { value: 11, limit: 15 } as PlatformUsage,
|
||||
ios = { value: 3, limit: 15 } as PlatformUsage,
|
||||
billingPeriodId = 'period-1',
|
||||
platformBreakdown = true,
|
||||
} = {}): Awaited<ReturnType<typeof AccountQuery.getFreeTierBuildUsageAsync>> {
|
||||
return {
|
||||
id: 'account-id',
|
||||
subscription: subscriptionName === null ? null : { id: 'subscription-id', name: subscriptionName },
|
||||
billingPeriod: {
|
||||
id: billingPeriodId,
|
||||
start: '2026-10-01T00:00:00.000Z',
|
||||
end: '2026-11-01T00:00:00.000Z',
|
||||
},
|
||||
usageMetrics: {
|
||||
EAS_BUILD: {
|
||||
id: 'eas-build',
|
||||
planMetrics: [
|
||||
{
|
||||
id: 'builds',
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: android.value + ios.value,
|
||||
limit: android.limit + ios.limit,
|
||||
platformBreakdown: platformBreakdown ? { android, ios } : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountIdsByVariant = findAccountIdsByVariant(ACCOUNT_DEFINITION, ['control', 'treatment']);
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
/** An account outside the partial rollout. */
|
||||
const notEnrolledAccountId = findAccountIdsByVariant(PARTIAL_ROLLOUT_DEFINITION, [null]).get(null)!;
|
||||
|
||||
/** In-memory stand-in for the server-side user preference store, keyed by preference key. */
|
||||
let shownTips: Record<string, unknown>;
|
||||
let logSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
shownTips = {};
|
||||
jest
|
||||
.mocked(UserPreferenceQuery.getValueAsync)
|
||||
.mockImplementation(async (_graphqlClient, key) => shownTips[key] ?? null);
|
||||
jest
|
||||
.mocked(UserPreferenceMutation.setAsync)
|
||||
.mockImplementation(async (_graphqlClient, key, value) => {
|
||||
shownTips[key] = value;
|
||||
});
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue({ id: 'claude-code', sessionId: 's-1' });
|
||||
jest.mocked(createGraphqlClient).mockReturnValue(GRAPHQL_CLIENT);
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(false);
|
||||
jest.mocked(isUsingEASUpdate).mockReturnValue(false);
|
||||
jest.mocked(AccountQuery.getFreeTierBuildUsageAsync).mockResolvedValue(usageResponse());
|
||||
logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe(getFreeTierNearLimitAgentUpdatesTipExperiment, () => {
|
||||
const ctx = {
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics: createMockAnalytics(),
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId: 'account-id',
|
||||
user: USER,
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
easJsonCliConfig: { updateManifestHostOverride: 'https://u.example.com' },
|
||||
runFromCI: false,
|
||||
};
|
||||
|
||||
it('returns the handle when the command has an experimentation client', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, experimentation } },
|
||||
{ json: true }
|
||||
)
|
||||
).toEqual({
|
||||
experimentation,
|
||||
graphqlClient: GRAPHQL_CLIENT,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId: 'account-id',
|
||||
isUser: true,
|
||||
runFromCI: false,
|
||||
json: true,
|
||||
projectByPlatform: {
|
||||
[AppPlatform.Android]: {
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
manifestHostOverride: 'https://u.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the project state of each platform', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{
|
||||
[AppPlatform.Android]: { ...ctx, experimentation },
|
||||
[AppPlatform.Ios]: {
|
||||
...ctx,
|
||||
experimentation,
|
||||
exp: IOS_EXP,
|
||||
developmentClient: true,
|
||||
easJsonCliConfig: undefined,
|
||||
},
|
||||
},
|
||||
{ json: false }
|
||||
)?.projectByPlatform
|
||||
).toEqual({
|
||||
[AppPlatform.Android]: project({ manifestHostOverride: 'https://u.example.com' }),
|
||||
[AppPlatform.Ios]: project({ exp: IOS_EXP, developmentClient: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates one GraphQL client with a request timeout', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{
|
||||
[AppPlatform.Android]: { ...ctx, experimentation },
|
||||
[AppPlatform.Ios]: { ...ctx, experimentation },
|
||||
},
|
||||
{ json: false }
|
||||
);
|
||||
|
||||
expect(createGraphqlClient).toHaveBeenCalledTimes(1);
|
||||
expect(createGraphqlClient).toHaveBeenCalledWith(AUTHENTICATION_INFO, {
|
||||
requestTimeoutMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a regular user', USER, true],
|
||||
['an SSO user', SSO_USER, true],
|
||||
['a robot', ROBOT, false],
|
||||
['a partner actor', PARTNER, false],
|
||||
])('marks %s with isUser %p', (_label, user, isUser) => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, user, experimentation } },
|
||||
{ json: false }
|
||||
)?.isUser
|
||||
).toBe(isUser);
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment({ [AppPlatform.Android]: ctx }, { json: false })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no authentication info', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, authenticationInfo: undefined, experimentation } },
|
||||
{ json: false }
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(createGraphqlClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined without build contexts', () => {
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipExperiment({}, { json: false })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('accepts agent runs', () => {
|
||||
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects runs without a coding agent', () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
|
||||
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs from CI', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { runFromCI: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects robot and partner actors, which cannot store user preferences', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { isUser: false }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects --json runs', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { json: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('accepts a project without Expo Update', () => {
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a project with expo-updates installed but no Expo Update URL', () => {
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a project that is already set up for Expo Update', () => {
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
jest.mocked(isUsingEASUpdate).mockReturnValue(true);
|
||||
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(false);
|
||||
expect(isUsingEASUpdate).toHaveBeenCalledWith(EXP, 'project-id', null);
|
||||
});
|
||||
|
||||
it('rejects development builds, which load JavaScript from the dev server', () => {
|
||||
expect(
|
||||
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project({ developmentClient: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isWithinFreeTierNearLimitBand, () => {
|
||||
it.each([
|
||||
[9, false],
|
||||
[10, true],
|
||||
[11, true],
|
||||
[14, true],
|
||||
[15, false],
|
||||
[16, false],
|
||||
])('used %s of 15 → %s', (used, expected) => {
|
||||
expect(isWithinFreeTierNearLimitBand({ used, limit: 15 })).toBe(expected);
|
||||
});
|
||||
|
||||
it('is false without a limit', () => {
|
||||
expect(isWithinFreeTierNearLimitBand({ used: 0, limit: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getFreeTierBuildUsageAsync, () => {
|
||||
it('returns the per-platform usage for a Free account', async () => {
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toEqual({
|
||||
billingPeriodId: 'period-1',
|
||||
byPlatform: {
|
||||
[AppPlatform.Android]: { used: 11, limit: 15 },
|
||||
[AppPlatform.Ios]: { used: 3, limit: 15 },
|
||||
},
|
||||
});
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'account-id',
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an account without a subscription as Free', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: null }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a paid plan', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: 'Starter' }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a platform breakdown', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ platformBreakdown: false }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierNearLimitAgentUpdatesTipVariantAsync, () => {
|
||||
it('returns the account variant and logs the exposure once', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client });
|
||||
|
||||
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
|
||||
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(controlAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure for an account outside the rollout', async () => {
|
||||
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(notEnrolledAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(treatmentAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getFreeTierNearLimitAgentUpdatesTipPreferenceKey, () => {
|
||||
it('scopes the key to the account and the lowercase platform', () => {
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Android)).toBe(
|
||||
ANDROID_KEY
|
||||
);
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Ios)).toBe(
|
||||
IOS_KEY
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('formats the Android tip', () => {
|
||||
expect(
|
||||
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Android, used: 11, limit: 15 })
|
||||
).toBe(ANDROID_TIP_11_OF_15);
|
||||
});
|
||||
|
||||
it('formats the iOS tip', () => {
|
||||
expect(
|
||||
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Ios, used: 14, limit: 15 })
|
||||
).toContain('Tip: this account has used 14 of 15 iOS builds on the Free plan this period.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync, () => {
|
||||
function treatmentExperiment(
|
||||
options: Parameters<typeof createExperiment>[1] = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment {
|
||||
return createExperiment('account-id', {
|
||||
client: createOverriddenClient('treatment'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
it('does nothing when the command has no experiment handle', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], undefined);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage for ineligible runs', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage when no build finished', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android, BuildStatus.Errored), null],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage when no finished platform is eligible', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment({
|
||||
projectByPlatform: { [AppPlatform.Android]: project({ developmentClient: true }) },
|
||||
})
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks each platform against its own build context', async () => {
|
||||
// Only the iOS build evaluated a config with the Expo Update URL.
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
jest.mocked(isUsingEASUpdate).mockImplementation(exp => exp === IOS_EXP);
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment({
|
||||
projectByPlatform: {
|
||||
[AppPlatform.Android]: project(),
|
||||
[AppPlatform.Ios]: project({ exp: IOS_EXP }),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('prints the tip in the treatment arm and remembers it for the billing period', async () => {
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
expect(UserPreferenceMutation.setAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY, {
|
||||
billingPeriodId: 'period-1',
|
||||
});
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
{
|
||||
...ANALYTICS_EVENT_PROPERTIES,
|
||||
platform: AppPlatform.Android,
|
||||
builds_used: 11,
|
||||
builds_limit: 15,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prints nothing and writes no marker in the control arm, but records the exposure', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(controlAccountId, { client, analytics });
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(shownTips).toEqual({});
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
expect(analytics.logEvent).not.toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing for an account outside the experiment', async () => {
|
||||
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
|
||||
const experiment = createExperiment(notEnrolledAccountId, { client, analytics });
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(shownTips).toEqual({});
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the exposure only after the usage checks pass', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ android: { value: 9, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
createExperiment(treatmentAccountId, { client, analytics })
|
||||
);
|
||||
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceQuery.getValueAsync).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for paid plans', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: 'Production' }));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not repeat the tip within the same billing period for this user', async () => {
|
||||
shownTips = { [ANDROID_KEY]: { billingPeriodId: 'period-1' } };
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(UserPreferenceQuery.getValueAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY);
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the tip again in a new billing period', async () => {
|
||||
const otherAccountIosKey = 'free-tier-near-limit-agent-updates-tip-shown/other-account/ios';
|
||||
shownTips = {
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-0' },
|
||||
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
|
||||
};
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
|
||||
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a malformed stored preference as not shown', async () => {
|
||||
shownTips = { [ANDROID_KEY]: 'period-1' };
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('checks each finished platform with one usage request', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
|
||||
);
|
||||
expect(shownTips).toEqual({
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
|
||||
[IOS_KEY]: { billingPeriodId: 'period-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips platforms outside the band', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('still shows the tip and logs the events when the marker write fails', async () => {
|
||||
jest.mocked(UserPreferenceMutation.setAsync).mockRejectedValue(new Error('forbidden'));
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client, analytics });
|
||||
|
||||
await expect(
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
expect.objectContaining({ platform: AppPlatform.Android })
|
||||
);
|
||||
expect(shownTips).toEqual({});
|
||||
});
|
||||
|
||||
it('still checks the next platform when a marker write fails', async () => {
|
||||
jest
|
||||
.mocked(UserPreferenceMutation.setAsync)
|
||||
.mockRejectedValueOnce(new Error('forbidden'))
|
||||
.mockImplementation(async (_graphqlClient, key, value) => {
|
||||
shownTips[key] = value;
|
||||
});
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
|
||||
);
|
||||
expect(shownTips).toEqual({ [IOS_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('fails open when the usage request fails', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockRejectedValue(new Error('network down'));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await expect(
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,8 @@ import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getErrorAsync } from '../../__tests__/commands/utils';
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import Build from '../../commands/build';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import Log, { link } from '../../log';
|
||||
import { handleBuildRequestErrorAsync } from '../build';
|
||||
import {
|
||||
@@ -30,12 +26,7 @@ import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../freeTierLimitCheckout', () => ({
|
||||
...jest.requireActual('../freeTierLimitCheckout'),
|
||||
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
|
||||
@@ -45,26 +36,9 @@ const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckout
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** An experiment handle whose client is forced to the given variant through an override. */
|
||||
function createExperiment(variant: string): FreeTierLimitAgentGuidanceExperiment {
|
||||
return {
|
||||
accountId: 'account-id',
|
||||
experimentation: LazyExperimentationClient.fromClient(
|
||||
ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
|
||||
])
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
|
||||
{
|
||||
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
|
||||
@@ -357,115 +331,6 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the agent guidance experiment', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('appends the human required block for Android in the treatment arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the human required block for iOS in the treatment arm', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block in the control arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('control'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block to other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, undefined, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the checkout experiment', () => {
|
||||
it('offers the checkout with the limit message and marks the message as printed', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
@@ -482,7 +347,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
@@ -515,7 +379,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
@@ -544,7 +407,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
@@ -559,35 +421,6 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the agent guidance along when both experiments are present', async () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
await getErrorAsync(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
createExperiment('treatment'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the checkout for other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
@@ -599,7 +432,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
undefined,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,11 +34,6 @@ import {
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from './freeTierLimitCheckout';
|
||||
import {
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
maybeAppendFreeTierLimitAgentGuidanceAsync,
|
||||
} from './freeTierLimitGuidance';
|
||||
import { transformMetadata } from './graphql';
|
||||
import { LocalBuildMode, runLocalBuildAsync } from './local';
|
||||
import {
|
||||
@@ -236,7 +231,6 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
error,
|
||||
job.platform,
|
||||
ctx.accountName,
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx),
|
||||
getFreeTierLimitCheckoutExperiment(ctx)
|
||||
);
|
||||
}
|
||||
@@ -263,7 +257,6 @@ export async function handleBuildRequestErrorAsync(
|
||||
error: any,
|
||||
platform: Platform,
|
||||
accountName?: string,
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
|
||||
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
|
||||
): Promise<never> {
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
@@ -283,11 +276,7 @@ export async function handleBuildRequestErrorAsync(
|
||||
if (!isFreeTierLimitError) {
|
||||
throw new ErrorClass(message);
|
||||
}
|
||||
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
);
|
||||
const limitMessage = `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`;
|
||||
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
|
||||
limitMessage,
|
||||
freeTierLimitCheckoutExperiment
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import { requestedPlatformDisplayNames } from '../platform';
|
||||
|
||||
export const FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE =
|
||||
'cli-build-free-tier-limit-agent-guidance';
|
||||
|
||||
export type FreeTierLimitAgentGuidanceVariant = 'control' | 'treatment';
|
||||
|
||||
export type FreeTierLimitAgentGuidanceExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export function getFreeTierLimitAgentGuidanceExperiment(ctx: {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
accountId: string;
|
||||
}): FreeTierLimitAgentGuidanceExperiment | undefined {
|
||||
return ctx.experimentation
|
||||
? { experimentation: ctx.experimentation, accountId: ctx.accountId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the account's variant. Only runs driven by a coding agent are eligible. Other runs get
|
||||
* control before the client loads, so they make no config request and log no exposure.
|
||||
*/
|
||||
export async function resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation,
|
||||
accountId,
|
||||
}: FreeTierLimitAgentGuidanceExperiment): Promise<FreeTierLimitAgentGuidanceVariant> {
|
||||
if (!getAgentTelemetryContext()) {
|
||||
return 'control';
|
||||
}
|
||||
const client = await experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, { accountId })
|
||||
.getParam<FreeTierLimitAgentGuidanceVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
export function formatFreeTierLimitAgentGuidance(platform: Platform): string {
|
||||
const platformName = requestedPlatformDisplayNames[platform];
|
||||
return `Fix: Human required: ${platformName} builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.`;
|
||||
}
|
||||
|
||||
export async function maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
message: string,
|
||||
platform: Platform,
|
||||
experiment: FreeTierLimitAgentGuidanceExperiment | undefined
|
||||
): Promise<string> {
|
||||
if (
|
||||
!experiment ||
|
||||
(await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment)) !== 'treatment'
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
return `${message}\n${formatFreeTierLimitAgentGuidance(platform)}`;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import {
|
||||
ExpoGraphqlClient,
|
||||
createGraphqlClient,
|
||||
} from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../graphql/generated';
|
||||
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
|
||||
import { AccountQuery } from '../graphql/queries/AccountQuery';
|
||||
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
|
||||
import Log from '../log';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../project/projectUtils';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE =
|
||||
'cli-build-free-tier-near-limit-agent-updates-tip';
|
||||
|
||||
/** The tip shows when this many builds or fewer are left on the platform. */
|
||||
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT = 5;
|
||||
|
||||
export const EAS_UPDATE_GETTING_STARTED_URL = 'https://docs.expo.dev/eas-update/getting-started/';
|
||||
|
||||
/** Server-side user preference key prefix. The full key is `<prefix>/<accountId>/<platform>`. */
|
||||
const USER_PREFERENCE_KEY_PREFIX = 'free-tier-near-limit-agent-updates-tip-shown';
|
||||
|
||||
/** The tip's requests run after the build, so a stalled request must not delay the exit. */
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipVariant = 'control' | 'treatment';
|
||||
|
||||
/** The project state of one platform's build. Each build profile evaluates its own config. */
|
||||
export type FreeTierNearLimitAgentUpdatesTipPlatformProject = {
|
||||
developmentClient: boolean;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
manifestHostOverride: string | null;
|
||||
};
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
accountId: string;
|
||||
/** Only regular and SSO users can store user preferences. */
|
||||
isUser: boolean;
|
||||
runFromCI: boolean;
|
||||
json: boolean;
|
||||
projectByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipPlatformProject>>;
|
||||
};
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipBuildContext = {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
accountId: string;
|
||||
user: Actor;
|
||||
developmentClient: boolean;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
easJsonCliConfig: { updateManifestHostOverride?: string } | undefined;
|
||||
runFromCI: boolean;
|
||||
};
|
||||
|
||||
export type FreeTierPlatformBuildUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type FreeTierBuildUsage = {
|
||||
billingPeriodId: string;
|
||||
byPlatform: Record<AppPlatform, FreeTierPlatformBuildUsage>;
|
||||
};
|
||||
|
||||
/** All build contexts belong to one command run, so the account and the actor are shared. */
|
||||
export function getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
buildCtxByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipBuildContext>>,
|
||||
{ json }: { json: boolean }
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment | undefined {
|
||||
const ctx = Object.values(buildCtxByPlatform)[0];
|
||||
if (!ctx?.experimentation || !ctx.authenticationInfo) {
|
||||
return undefined;
|
||||
}
|
||||
const projectByPlatform: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'] = {};
|
||||
for (const [platform, platformCtx] of Object.entries(buildCtxByPlatform)) {
|
||||
projectByPlatform[platform as AppPlatform] = {
|
||||
developmentClient: platformCtx.developmentClient,
|
||||
projectDir: platformCtx.projectDir,
|
||||
projectId: platformCtx.projectId,
|
||||
exp: platformCtx.exp,
|
||||
manifestHostOverride: platformCtx.easJsonCliConfig?.updateManifestHostOverride ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
experimentation: ctx.experimentation,
|
||||
graphqlClient: createGraphqlClient(ctx.authenticationInfo, {
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
}),
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ctx.analyticsEventProperties,
|
||||
accountId: ctx.accountId,
|
||||
isUser: ctx.user.__typename === 'User' || ctx.user.__typename === 'SSOUser',
|
||||
runFromCI: ctx.runFromCI,
|
||||
json,
|
||||
projectByPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
export function isProjectConfiguredForEASUpdate({
|
||||
projectDir,
|
||||
exp,
|
||||
projectId,
|
||||
manifestHostOverride,
|
||||
}: Pick<
|
||||
FreeTierNearLimitAgentUpdatesTipPlatformProject,
|
||||
'projectDir' | 'exp' | 'projectId' | 'manifestHostOverride'
|
||||
>): boolean {
|
||||
return (
|
||||
isExpoUpdatesInstalled(projectDir) && isUsingEASUpdate(exp, projectId, manifestHostOverride)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only runs driven by a coding agent logged in as a user, outside CI and without --json are
|
||||
* eligible. Everything else gets no tip and no usage request.
|
||||
*/
|
||||
export function isEligibleForFreeTierNearLimitAgentUpdatesTip(
|
||||
experiment: FreeTierNearLimitAgentUpdatesTipExperiment
|
||||
): boolean {
|
||||
return !(
|
||||
experiment.runFromCI ||
|
||||
experiment.json ||
|
||||
!experiment.isUser ||
|
||||
!getAgentTelemetryContext()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform is eligible when its project is not set up for Expo Update. Development builds load
|
||||
* JavaScript from the dev server, so the tip does not apply to them.
|
||||
*/
|
||||
export function isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(
|
||||
project: FreeTierNearLimitAgentUpdatesTipPlatformProject
|
||||
): boolean {
|
||||
return !project.developmentClient && !isProjectConfiguredForEASUpdate(project);
|
||||
}
|
||||
|
||||
/** True when the platform still has builds left, but no more than the tip's threshold. */
|
||||
export function isWithinFreeTierNearLimitBand({ used, limit }: FreeTierPlatformBuildUsage): boolean {
|
||||
return limit > 0 && used < limit && limit - used <= FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT;
|
||||
}
|
||||
|
||||
/** Returns the Free plan build usage per platform, or null when the account is not on the Free plan. */
|
||||
export async function getFreeTierBuildUsageAsync({
|
||||
graphqlClient,
|
||||
accountId,
|
||||
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'graphqlClient' | 'accountId'>): Promise<FreeTierBuildUsage | null> {
|
||||
const account = await AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, accountId, new Date());
|
||||
if ((account.subscription?.name ?? 'Free') !== 'Free') {
|
||||
return null;
|
||||
}
|
||||
const buildMetric = account.usageMetrics.EAS_BUILD.planMetrics.find(
|
||||
metric =>
|
||||
metric.serviceMetric === EasServiceMetric.Builds &&
|
||||
metric.metricType === UsageMetricType.Build
|
||||
);
|
||||
const breakdown = buildMetric?.platformBreakdown;
|
||||
if (!breakdown) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
billingPeriodId: account.billingPeriod.id,
|
||||
byPlatform: {
|
||||
[AppPlatform.Android]: { used: breakdown.android.value, limit: breakdown.android.limit },
|
||||
[AppPlatform.Ios]: { used: breakdown.ios.value, limit: breakdown.ios.limit },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The first call logs the exposure, so call it only when the tip would show. Accounts outside the
|
||||
* rollout get the control default and no exposure.
|
||||
*/
|
||||
export async function resolveFreeTierNearLimitAgentUpdatesTipVariantAsync({
|
||||
experimentation,
|
||||
accountId,
|
||||
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'experimentation' | 'accountId'>): Promise<FreeTierNearLimitAgentUpdatesTipVariant> {
|
||||
const client = await experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, { accountId })
|
||||
.getParam<FreeTierNearLimitAgentUpdatesTipVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
export function formatFreeTierNearLimitAgentUpdatesTip({
|
||||
platform,
|
||||
used,
|
||||
limit,
|
||||
}: FreeTierPlatformBuildUsage & { platform: AppPlatform }): string {
|
||||
const platformName = appPlatformDisplayNames[platform];
|
||||
return [
|
||||
`Tip: this account has used ${used} of ${limit} ${platformName} builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.`,
|
||||
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
|
||||
'Next: eas update:configure --non-interactive, then build once more.',
|
||||
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
|
||||
`Docs: ${EAS_UPDATE_GETTING_STARTED_URL}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function getFreeTierNearLimitAgentUpdatesTipPreferenceKey(
|
||||
accountId: string,
|
||||
platform: AppPlatform
|
||||
): string {
|
||||
return `${USER_PREFERENCE_KEY_PREFIX}/${accountId}/${platform.toLowerCase()}`;
|
||||
}
|
||||
|
||||
type ShownTipPreference = { billingPeriodId: string };
|
||||
|
||||
function isShownTipPreference(value: unknown): value is ShownTipPreference {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as ShownTipPreference).billingPeriodId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tip shows at most once per user, account, platform and billing period. The marker is a
|
||||
* user preference on the server, so it follows the user across machines and fresh agent
|
||||
* sandboxes.
|
||||
*/
|
||||
export async function wasFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
platform: AppPlatform,
|
||||
billingPeriodId: string
|
||||
): Promise<boolean> {
|
||||
const value = await UserPreferenceQuery.getValueAsync(
|
||||
graphqlClient,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform)
|
||||
);
|
||||
return isShownTipPreference(value) && value.billingPeriodId === billingPeriodId;
|
||||
}
|
||||
|
||||
export async function markFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
platform: AppPlatform,
|
||||
billingPeriodId: string
|
||||
): Promise<void> {
|
||||
const value: ShownTipPreference = { billingPeriodId };
|
||||
await UserPreferenceMutation.setAsync(
|
||||
graphqlClient,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After finished builds, suggests Expo Update to coding agents whose Free plan account is close to
|
||||
* the platform build limit. The exposure logs only when every eligibility check passed, so both
|
||||
* arms see the same population. The treatment arm prints the tip, logs the tip event, then
|
||||
* writes the marker, so a failed write means at most a repeated tip. Fails open on any error.
|
||||
*/
|
||||
export async function maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
builds: (BuildFragment | null)[],
|
||||
experiment: FreeTierNearLimitAgentUpdatesTipExperiment | undefined
|
||||
): Promise<void> {
|
||||
if (!experiment || !isEligibleForFreeTierNearLimitAgentUpdatesTip(experiment)) {
|
||||
return;
|
||||
}
|
||||
const eligiblePlatforms = new Set(
|
||||
builds
|
||||
.filter((build): build is BuildFragment => build?.status === BuildStatus.Finished)
|
||||
.map(build => build.platform)
|
||||
.filter(platform => {
|
||||
const project = experiment.projectByPlatform[platform];
|
||||
return project && isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project);
|
||||
})
|
||||
);
|
||||
if (eligiblePlatforms.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await getFreeTierBuildUsageAsync(experiment);
|
||||
if (!usage) {
|
||||
return;
|
||||
}
|
||||
for (const platform of eligiblePlatforms) {
|
||||
const platformUsage = usage.byPlatform[platform];
|
||||
if (
|
||||
!isWithinFreeTierNearLimitBand(platformUsage) ||
|
||||
(await wasFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
experiment.graphqlClient,
|
||||
experiment.accountId,
|
||||
platform,
|
||||
usage.billingPeriodId
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const variant = await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment);
|
||||
if (variant !== 'treatment') {
|
||||
continue;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(formatFreeTierNearLimitAgentUpdatesTip({ platform, ...platformUsage }));
|
||||
experiment.analytics.logEvent(BuildEvent.FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP, {
|
||||
...experiment.analyticsEventProperties,
|
||||
platform,
|
||||
builds_used: platformUsage.used,
|
||||
builds_limit: platformUsage.limit,
|
||||
});
|
||||
try {
|
||||
await markFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
experiment.graphqlClient,
|
||||
experiment.accountId,
|
||||
platform,
|
||||
usage.billingPeriodId
|
||||
);
|
||||
} catch (error: any) {
|
||||
// The tip was shown. A missing marker means at most a repeated tip, so keep going.
|
||||
Log.debug('Failed to mark the Expo Update tip as shown:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
Log.debug('Failed to run the Expo Update tip check:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import { prepareIosBuildAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { ensureLockfileExistsAsync } from './validateLockfile';
|
||||
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
|
||||
import {
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
|
||||
} from './freeTierNearLimitAgentUpdatesTip';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
@@ -139,6 +143,7 @@ export async function runBuildAndSubmitAsync({
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
builds?: MaybeBuildFragment[];
|
||||
}> {
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
@@ -339,6 +344,10 @@ export async function runBuildAndSubmitAsync({
|
||||
if (!flags.json) {
|
||||
printBuildResults(builds);
|
||||
}
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
builds,
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(buildCtxByPlatform, { json: flags.json })
|
||||
);
|
||||
|
||||
const haveAllBuildsFailedOrCanceled = builds.every(
|
||||
build =>
|
||||
@@ -372,6 +381,7 @@ export async function runBuildAndSubmitAsync({
|
||||
return {
|
||||
buildIds: startedBuilds.map(({ build }) => build.id),
|
||||
buildProfiles,
|
||||
builds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
import { Analytics, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
|
||||
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
|
||||
import Log, { link } from '../log';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
const SIMULATORS_LAUNCH_AT = new Date('2026-11-05T18:00:00Z');
|
||||
const SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
const SIMULATORS_QUICKSTART_URL =
|
||||
'https://docs.expo.dev/preview/simulators/get-started/?utm_source=eas-cli&utm_medium=cli&utm_campaign=simulators-launch';
|
||||
|
||||
export async function maybeShowSimulatorsLaunchHintAsync(
|
||||
authenticationInfo: LoggedInAuthenticationInfo,
|
||||
analytics: Analytics,
|
||||
{ actor, nonInteractive }: { actor: Actor; nonInteractive: boolean }
|
||||
): Promise<void> {
|
||||
if (
|
||||
nonInteractive ||
|
||||
boolish('CI', false) ||
|
||||
Date.now() < SIMULATORS_LAUNCH_AT.getTime() ||
|
||||
(actor.__typename !== 'User' && actor.__typename !== 'SSOUser')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo, {
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const value = await UserPreferenceQuery.getValueAsync(
|
||||
graphqlClient,
|
||||
SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY
|
||||
);
|
||||
if (value && typeof value === 'object' && 'isDismissed' in value && value.isDismissed) {
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`✨ New: run your build in Expo Simulators, cloud Android and iOS simulators for you and your agents. ${link(SIMULATORS_QUICKSTART_URL)}`
|
||||
);
|
||||
analytics.logEvent(BuildEvent.SIMULATORS_LAUNCH_HINT_SHOWN, {});
|
||||
await UserPreferenceMutation.setAsync(graphqlClient, SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY, {
|
||||
isDismissed: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Log.debug('Failed to show the Expo Simulators hint', error);
|
||||
}
|
||||
}
|
||||
@@ -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:}`,
|
||||
|
||||
@@ -9,12 +9,13 @@ import path from 'path';
|
||||
|
||||
import { LocalBuildMode } from '../../build/local';
|
||||
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { maybeShowSimulatorsLaunchHintAsync } from '../../build/simulatorsLaunchHint';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { BuildStatus, StatuspageServiceName } from '../../graphql/generated';
|
||||
import Log, { link } from '../../log';
|
||||
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -176,7 +177,7 @@ export default class Build extends EasCommand {
|
||||
|
||||
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
|
||||
|
||||
const { buildProfiles } = await runBuildAndSubmitAsync({
|
||||
const { buildProfiles, builds } = await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
@@ -192,6 +193,13 @@ export default class Build extends EasCommand {
|
||||
buildProfiles,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
|
||||
if (builds?.some(build => build?.status === BuildStatus.Finished)) {
|
||||
await maybeShowSimulatorsLaunchHintAsync(authenticationInfo, analytics, {
|
||||
actor,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlags(
|
||||
|
||||
@@ -112,6 +112,7 @@ describe(ObserveSession, () => {
|
||||
metadata: null,
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: false,
|
||||
hasMoreSpans: false,
|
||||
});
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { buildObserveTraceJson, buildObserveTraceTable } from '../../../observe/formatSpans';
|
||||
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveTrace from '../trace';
|
||||
|
||||
jest.mock('../../../observe/formatSpans', () => ({
|
||||
buildObserveTraceTable: jest.fn().mockReturnValue('trace-table'),
|
||||
buildObserveTraceJson: jest.fn().mockReturnValue({ id: 'trace-json' }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
traceByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockTraceByIdAsync = jest.mocked(ObserveQuery.traceByIdAsync);
|
||||
const mockBuildObserveTraceTable = jest.mocked(buildObserveTraceTable);
|
||||
const mockBuildObserveTraceJson = jest.mocked(buildObserveTraceJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
const TRACE_ID = '0123456789abcdef0123456789abcdef';
|
||||
const trace = { id: TRACE_ID, isTruncated: false, spans: [] } as any;
|
||||
|
||||
describe(ObserveTrace, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockTraceByIdAsync.mockResolvedValue(trace);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveTrace {
|
||||
const command = new ObserveTrace(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('requires a trace ID argument', async () => {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow();
|
||||
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed trace ID before querying', async () => {
|
||||
await expect(createCommand(['not-a-trace-id']).runAsync()).rejects.toThrow(
|
||||
/not a valid trace ID/
|
||||
);
|
||||
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an uppercase trace ID', async () => {
|
||||
await createCommand([TRACE_ID.toUpperCase()]).runAsync();
|
||||
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: TRACE_ID.toUpperCase(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the trace table by default', async () => {
|
||||
await createCommand([TRACE_ID]).runAsync();
|
||||
|
||||
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: TRACE_ID,
|
||||
});
|
||||
expect(mockBuildObserveTraceTable).toHaveBeenCalledWith(trace);
|
||||
expect(mockBuildObserveTraceJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with --json', async () => {
|
||||
await createCommand([TRACE_ID, '--json', '--non-interactive']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveTraceJson).toHaveBeenCalledWith(trace);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ id: 'trace-json' });
|
||||
expect(mockBuildObserveTraceTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with a not-found message when the trace is unknown', async () => {
|
||||
mockTraceByIdAsync.mockResolvedValue(null);
|
||||
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(/No trace found with ID/);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message', async () => {
|
||||
const serverMessage =
|
||||
'Access to this feature is not included in your current plan. ' +
|
||||
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
|
||||
mockTraceByIdAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObservePlatform,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanStatusCode,
|
||||
} from '../../../graphql/generated';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveSpansAsync } from '../../../observe/fetchSpans';
|
||||
import {
|
||||
buildObserveSpanNamesJson,
|
||||
buildObserveSpanNamesTable,
|
||||
buildObserveSpansJson,
|
||||
buildObserveSpansTable,
|
||||
} from '../../../observe/formatSpans';
|
||||
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveTraces from '../traces';
|
||||
|
||||
jest.mock('../../../observe/fetchSpans');
|
||||
jest.mock('../../../observe/formatSpans', () => ({
|
||||
buildObserveSpansTable: jest.fn().mockReturnValue('spans-table'),
|
||||
buildObserveSpansJson: jest.fn().mockReturnValue({ spans: [] }),
|
||||
buildObserveSpanNamesTable: jest.fn().mockReturnValue('names-table'),
|
||||
buildObserveSpanNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
spanNamesAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSpansAsync = jest.mocked(fetchObserveSpansAsync);
|
||||
const mockSpanNamesAsync = jest.mocked(ObserveQuery.spanNamesAsync);
|
||||
const mockBuildObserveSpansTable = jest.mocked(buildObserveSpansTable);
|
||||
const mockBuildObserveSpansJson = jest.mocked(buildObserveSpansJson);
|
||||
const mockBuildObserveSpanNamesTable = jest.mocked(buildObserveSpanNamesTable);
|
||||
const mockBuildObserveSpanNamesJson = jest.mocked(buildObserveSpanNamesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveTraces, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveSpansAsync.mockResolvedValue({
|
||||
spans: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockSpanNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveTraces {
|
||||
const command = new ObserveTraces(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Access to this feature is not included in your current plan. ' +
|
||||
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('shows the span names summary when no span filter is given', async () => {
|
||||
const command = createCommand(['--days', '7', '--platform', 'ios', '--environment', 'prod']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).toHaveBeenCalledTimes(1);
|
||||
const variables = mockSpanNamesAsync.mock.calls[0][1];
|
||||
expect(variables.appId).toBe(projectId);
|
||||
expect(variables.input.platforms).toEqual([AppObservePlatform.Ios]);
|
||||
expect(variables.input.environment).toBe('prod');
|
||||
expect(variables.input.startTime).toBeDefined();
|
||||
expect(variables.input.endTime).toBeDefined();
|
||||
expect(mockFetchObserveSpansAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesTable).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes release filters to the summary', async () => {
|
||||
const command = createCommand([
|
||||
'--app-version',
|
||||
'1.2.0',
|
||||
'--build-number',
|
||||
'42',
|
||||
'--update-id',
|
||||
'update-1',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const { input } = mockSpanNamesAsync.mock.calls[0][1];
|
||||
expect(input.appVersion).toBe('1.2.0');
|
||||
expect(input.appBuildNumber).toBe('42');
|
||||
expect(input.appUpdateId).toBe('update-1');
|
||||
});
|
||||
|
||||
it('emits the summary as JSON with --json', async () => {
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists spans with --all-spans', async () => {
|
||||
const command = createCommand(['--all-spans']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveSpansTable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists spans when paging with --after', async () => {
|
||||
const command = createCommand(['--after', 'cursor-1']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].after).toBe('cursor-1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--name', 'GET'],
|
||||
['--kind', 'client'],
|
||||
['--status', 'error'],
|
||||
['--host', 'api.example.com'],
|
||||
['--http-status', '500'],
|
||||
['--min-duration', '100'],
|
||||
['--session-id', 'session-1'],
|
||||
])('lists spans when %s is given', async (flag, value) => {
|
||||
const command = createCommand([flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists spans with --root-only', async () => {
|
||||
const command = createCommand(['--root-only']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].rootOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('maps every span filter flag to the fetch options', async () => {
|
||||
const command = createCommand([
|
||||
'--name',
|
||||
'GET',
|
||||
'--name',
|
||||
'POST',
|
||||
'--kind',
|
||||
'client',
|
||||
'--status',
|
||||
'error',
|
||||
'--status',
|
||||
'unset',
|
||||
'--host',
|
||||
'api.example.com',
|
||||
'--http-status',
|
||||
'500',
|
||||
'--http-status',
|
||||
'502',
|
||||
'--min-duration',
|
||||
'250',
|
||||
'--root-only',
|
||||
'--session-id',
|
||||
'session-1',
|
||||
'--platform',
|
||||
'android',
|
||||
'--app-version',
|
||||
'1.2.0',
|
||||
'--build-number',
|
||||
'42',
|
||||
'--update-id',
|
||||
'update-1',
|
||||
'--environment',
|
||||
'prod',
|
||||
'--limit',
|
||||
'50',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveSpansAsync.mock.calls[0][2];
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
limit: 50,
|
||||
names: ['GET', 'POST'],
|
||||
kinds: [AppObserveSpanKind.Client],
|
||||
statusCodes: [AppObserveSpanStatusCode.Error, AppObserveSpanStatusCode.Unset],
|
||||
hosts: ['api.example.com'],
|
||||
httpStatusCodes: [500, 502],
|
||||
minDurationMs: 250,
|
||||
rootOnly: true,
|
||||
sessionId: 'session-1',
|
||||
platforms: [AppObservePlatform.Android],
|
||||
appVersion: '1.2.0',
|
||||
buildNumber: '42',
|
||||
updateId: 'update-1',
|
||||
environment: 'prod',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults the span list limit to 20 and caps it at 100', async () => {
|
||||
await createCommand(['--all-spans']).runAsync();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].limit).toBe(20);
|
||||
|
||||
await expect(createCommand(['--all-spans', '--limit', '101']).runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown --kind or --status value', async () => {
|
||||
await expect(createCommand(['--kind', 'bogus']).runAsync()).rejects.toThrow();
|
||||
await expect(createCommand(['--status', 'bogus']).runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('emits the span list as JSON with --json', async () => {
|
||||
const command = createCommand(['--all-spans', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when a session filter is not available on the plan', async () => {
|
||||
mockFetchObserveSpansAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand(['--session-id', 'session-1']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message from the summary query', async () => {
|
||||
mockSpanNamesAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const PICKER_CANDIDATE_LIMIT = 25;
|
||||
|
||||
export default class ObserveSession extends EasCommand {
|
||||
static override description =
|
||||
'display the timeline of metric and log events for a specific session';
|
||||
'display the timeline of metric, log, and request trace span events for a specific session';
|
||||
|
||||
static override args = {
|
||||
sessionId: Args.string({
|
||||
@@ -129,7 +129,7 @@ export default class ObserveSession extends EasCommand {
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents, hasMoreSpans } =
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId,
|
||||
@@ -144,7 +144,8 @@ export default class ObserveSession extends EasCommand {
|
||||
sessionId,
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents
|
||||
hasMoreLogEvents,
|
||||
hasMoreSpans
|
||||
)
|
||||
);
|
||||
} else {
|
||||
@@ -154,6 +155,7 @@ export default class ObserveSession extends EasCommand {
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents,
|
||||
hasMoreSpans,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Args } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import { buildObserveTraceJson, buildObserveTraceTable } from '../../observe/formatSpans';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// A trace ID is the 32-character hex string of the OTLP JSON encoding.
|
||||
const TRACE_ID_REGEX = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export default class ObserveTrace extends EasCommand {
|
||||
static override description =
|
||||
'display every span of a single request trace by its trace ID, oldest first, with child spans indented under their parent. Trace IDs are shown by `eas observe:traces`. In `--json` output a span with no `parentSpanId` is a root span, and a span with no `http` object is not an HTTP request';
|
||||
|
||||
static override args = {
|
||||
traceId: Args.string({
|
||||
description: 'ID of the trace to display',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveTrace);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const traceId = args.traceId;
|
||||
if (!TRACE_ID_REGEX.test(traceId)) {
|
||||
throw new EasCommandError(
|
||||
`"${traceId}" is not a valid trace ID. A trace ID is 32 hexadecimal characters; copy it from the Trace ID column of \`eas observe:traces\`.`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveTrace, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const trace = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.traceByIdAsync(graphqlClient, { appId: projectId, id: traceId })
|
||||
);
|
||||
|
||||
if (!trace) {
|
||||
throw new EasCommandError(
|
||||
`No trace found with ID "${traceId}". Trace IDs come from \`eas observe:traces\`, and traces age out of retention.`
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveTraceJson(trace));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveTraceTable(trace));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveSpansAsync } from '../../observe/fetchSpans';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveSpanNamesJson,
|
||||
buildObserveSpanNamesTable,
|
||||
buildObserveSpansJson,
|
||||
buildObserveSpansTable,
|
||||
} from '../../observe/formatSpans';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import {
|
||||
allowedSpanKindFlagValues,
|
||||
allowedSpanStatusFlagValues,
|
||||
spanKindsFromFlag,
|
||||
spanStatusCodesFromFlag,
|
||||
} from '../../observe/spanFilters';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_SPANS_LIMIT = 20;
|
||||
|
||||
export default class ObserveTraces extends EasCommand {
|
||||
static override description =
|
||||
'display request trace spans recorded by the app, newest first. With no span filters, a summary of span names and kinds with counts is returned. Pass --all-spans or a span filter (--name, --kind, --status, --host, --http-status, --min-duration, --root-only, --session-id) to list individual spans';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_SPANS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
'session-id': Flags.string({
|
||||
description: 'Filter by session ID',
|
||||
}),
|
||||
name: Flags.string({
|
||||
description: 'Filter by span name, such as GET or POST (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
kind: Flags.option({
|
||||
description: 'Filter by span kind (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: allowedSpanKindFlagValues,
|
||||
})(),
|
||||
status: Flags.option({
|
||||
description: 'Filter by span status (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: allowedSpanStatusFlagValues,
|
||||
})(),
|
||||
host: Flags.string({
|
||||
description: 'Filter by request host (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
'http-status': Flags.integer({
|
||||
description: 'Filter by HTTP response status code (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
'min-duration': Flags.integer({
|
||||
description: 'Only spans that took at least this many milliseconds',
|
||||
min: 0,
|
||||
}),
|
||||
'root-only': Flags.boolean({
|
||||
description: 'Only root spans (spans with no parent)',
|
||||
default: false,
|
||||
}),
|
||||
'all-spans': Flags.boolean({
|
||||
description:
|
||||
'List individual spans instead of the summary of span names + counts, even with no span filter',
|
||||
default: false,
|
||||
}),
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveTraces);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveTraces, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
|
||||
const hasSpanFilter =
|
||||
flags['session-id'] !== undefined ||
|
||||
(flags.name?.length ?? 0) > 0 ||
|
||||
(flags.kind?.length ?? 0) > 0 ||
|
||||
(flags.status?.length ?? 0) > 0 ||
|
||||
(flags.host?.length ?? 0) > 0 ||
|
||||
(flags['http-status']?.length ?? 0) > 0 ||
|
||||
flags['min-duration'] !== undefined ||
|
||||
flags['root-only'];
|
||||
|
||||
// The names summary has no span-level filters and no pagination, so any of
|
||||
// those (or --all-spans) means the caller wants the span list.
|
||||
if (!hasSpanFilter && !flags['all-spans'] && !flags.after) {
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.spanNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
input: {
|
||||
startTime,
|
||||
endTime,
|
||||
...(platforms?.length && { platforms }),
|
||||
...(flags.environment && { environment: flags.environment }),
|
||||
...(flags['app-version'] && { appVersion: flags['app-version'] }),
|
||||
...(flags['build-number'] && { appBuildNumber: flags['build-number'] }),
|
||||
...(flags['update-id'] && { appUpdateId: flags['update-id'] }),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveSpanNamesJson(names, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveSpanNamesTable(names, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
isTruncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { spans, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveSpansAsync(graphqlClient, projectId, {
|
||||
limit: flags.limit ?? DEFAULT_SPANS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
sessionId: flags['session-id'],
|
||||
names: flags.name,
|
||||
kinds: spanKindsFromFlag(flags.kind),
|
||||
statusCodes: spanStatusCodesFromFlag(flags.status),
|
||||
hosts: flags.host,
|
||||
httpStatusCodes: flags['http-status'],
|
||||
minDurationMs: flags['min-duration'],
|
||||
rootOnly: flags['root-only'],
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveSpansJson(spans, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveSpansTable(spans, pageInfo, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
sessionId: flags['session-id'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
+371
-10
@@ -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';
|
||||
/**
|
||||
@@ -15589,6 +15896,14 @@ export type CreateAppScopedUploadSessionMutationVariables = Exact<{
|
||||
|
||||
export type CreateAppScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAppScopedUploadSession: any } };
|
||||
|
||||
export type SetUserPreferenceMutationVariables = Exact<{
|
||||
key: Scalars['String']['input'];
|
||||
value: Scalars['JSON']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type SetUserPreferenceMutation = { __typename?: 'RootMutation', userPreference: { __typename?: 'UserPreferenceMutation', set: { __typename?: 'UserPreference', id: string, key: string, value: any } } };
|
||||
|
||||
export type CreateWebhookMutationVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
webhookInput: WebhookInput;
|
||||
@@ -15624,6 +15939,7 @@ export type GetOrCreateWorkflowRevisionFromGitRefMutation = { __typename?: 'Root
|
||||
export type ValidateWorkflowYamlConfigMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput> | WorkflowReferencedFileInput>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -15681,6 +15997,14 @@ export type AccountFullUsageQueryVariables = Exact<{
|
||||
|
||||
export type AccountFullUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, status?: string | null, nextInvoice?: any | null, nextInvoiceAmountDueCents?: number | null, recurringCents?: number | null, price: number, concurrencies?: { __typename?: 'Concurrencies', total: number, android: number, ios: number } | null, addons: Array<{ __typename?: 'AddonDetails', id: string, name: string, quantity?: number | null }>, upcomingInvoice?: { __typename?: 'Invoice', id: string, total: number, lineItems: Array<{ __typename?: 'InvoiceLineItem', id: string, description: string, amount: number, period: { __typename?: 'InvoicePeriod', start: any, end: any } }> } | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', MEDIUM_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, MEDIUM_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_UPDATE: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_SIMULATOR: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, unavailableServiceUsageMetrics: Array<{ __typename?: 'UnavailableServiceUsageMetric', service: EasService }>, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | nulLine truncated
|
||||
|
||||
export type AccountFreeTierBuildUsageQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
currentDate: Scalars['DateTime']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountFreeTierBuildUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null }> } } } } };
|
||||
|
||||
export type AccountUsageForOverageWarningQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
currentDate: Scalars['DateTime']['input'];
|
||||
@@ -15752,7 +16076,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 }
|
||||
@@ -16221,13 +16545,41 @@ export type AppObserveSessionEventsQueryVariables = Exact<{
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
metricsOrderBy?: InputMaybe<AppObserveMetricsListOrderBy>;
|
||||
logsOrderBy?: InputMaybe<AppObserveLogsOrderBy>;
|
||||
spansOrderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSessionEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', session: { __typename?: 'AppObserveSession', id: string, metrics: { __typename?: 'AppObserveMetricConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveMetricEdge', node: { __typename?: 'AppObserveMetric', id: string, name: string, value: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null, routeName?: string | null } }> }, logs: { __typename?: 'AppObserveLogConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveLogEdge', node:
|
||||
| { __typename: 'AppObserveError', id: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, type?: string | null, message?: string | null, source?: string | null, fingerprint?: string | null, isFatal?: boolean | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> }
|
||||
| { __typename: 'AppObserveUserEvent', id: string, name: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> }
|
||||
}> } } } } } };
|
||||
}> }, spans: { __typename?: 'AppObserveSpanConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveSpanEdge', node: { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> } }> } } } } } };
|
||||
|
||||
export type AppObserveSpanListQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter: AppObserveSpanListFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSpanListQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', spans: { __typename?: 'AppObserveSpanConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveSpanEdge', cursor: string, node: { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> } }> } } } } } };
|
||||
|
||||
export type AppObserveSpanNamesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveSpanNamesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSpanNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', names: { __typename?: 'AppObserveSpanNames', isTruncated: boolean, names: Array<{ __typename?: 'AppObserveSpanName', name: string, kind: AppObserveSpanKind, count: number }> } } } } } };
|
||||
|
||||
export type AppObserveTraceByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveTraceByIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', trace?: { __typename?: 'AppObserveTrace', id: string, isTruncated: boolean, spans: Array<{ __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> }> } | null } } } } };
|
||||
|
||||
export type AppObserveMetricByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
@@ -16461,6 +16813,13 @@ export type UpdateByIdQuery = { __typename?: 'RootQuery', updates: { __typename?
|
||||
| { __typename: 'User', username: string, id: string }
|
||||
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null } } };
|
||||
|
||||
export type UserPreferenceByKeyQueryVariables = Exact<{
|
||||
key: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UserPreferenceByKeyQuery = { __typename?: 'RootQuery', userPreference: { __typename?: 'UserPreferenceQuery', get?: { __typename?: 'UserPreference', id: string, key: string, value: any } | null } };
|
||||
|
||||
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -16598,7 +16957,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 +16977,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 }
|
||||
@@ -16751,6 +17110,8 @@ export type AppObserveErrorGroupFragment = { __typename?: 'AppObserveErrorGroup'
|
||||
|
||||
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
|
||||
|
||||
export type AppObserveSpanFragment = { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> };
|
||||
|
||||
export type PostHogOrganizationConnectionFragment = { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any };
|
||||
|
||||
export type PostHogProjectFragment = { __typename?: 'PostHogProject', id: string, posthogProjectIdentifier: string, posthogProjectName: string, posthogProjectToken: string, posthogHost: string, createdAt: any, updatedAt: any, posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } };
|
||||
@@ -16810,7 +17171,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 }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { SetUserPreferenceMutation, SetUserPreferenceMutationVariables } from '../generated';
|
||||
|
||||
export const UserPreferenceMutation = {
|
||||
/** Stores the JSON value under the key for the current user. Rejects for robot viewers. */
|
||||
async setAsync(graphqlClient: ExpoGraphqlClient, key: string, value: unknown): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<SetUserPreferenceMutation, SetUserPreferenceMutationVariables>(
|
||||
gql`
|
||||
mutation SetUserPreference($key: String!, $value: JSON!) {
|
||||
userPreference {
|
||||
set(key: $key, value: $value) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ key, value },
|
||||
{ additionalTypenames: ['UserPreference'], noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceMutation } from '../UserPreferenceMutation';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
mutation: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
mutation: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { mutation: jest.Mock };
|
||||
}
|
||||
|
||||
describe('UserPreferenceMutation.setAsync', () => {
|
||||
it('sets the preference by key', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
userPreference: {
|
||||
set: { id: 'preference-id', key: 'some-key', value: { billingPeriodId: 'period-1' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
UserPreferenceMutation.setAsync(graphqlClient, 'some-key', { billingPeriodId: 'period-1' })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const [document, variables, context] = graphqlClient.mutation.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('mutation SetUserPreference');
|
||||
expect(printed).toContain('set(key: $key, value: $value)');
|
||||
expect(variables).toEqual({ key: 'some-key', value: { billingPeriodId: 'period-1' } });
|
||||
expect(context).toEqual({ additionalTypenames: ['UserPreference'], noRetry: true });
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('forbidden') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(
|
||||
UserPreferenceMutation.setAsync(graphqlClient, 'some-key', { billingPeriodId: 'period-1' })
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountByNameQuery as AccountByNameQueryType,
|
||||
AccountByNameQueryVariables,
|
||||
AccountFreeTierBuildUsageQuery,
|
||||
AccountFreeTierBuildUsageQueryVariables,
|
||||
AccountFullUsageQuery as AccountFullUsageQueryType,
|
||||
AccountFullUsageQueryVariables,
|
||||
AccountSubscriptionQuery as AccountSubscriptionQueryType,
|
||||
@@ -21,6 +23,9 @@ import {
|
||||
} from '../types/Account';
|
||||
|
||||
export type AccountFullUsageData = NonNullable<AccountFullUsageQueryType['account']['byId']>;
|
||||
export type AccountFreeTierBuildUsageData = NonNullable<
|
||||
AccountFreeTierBuildUsageQuery['account']['byId']
|
||||
>;
|
||||
export type AccountSubscriptionInfo = NonNullable<
|
||||
NonNullable<AccountSubscriptionQueryType['account']['byId']>['subscription']
|
||||
>;
|
||||
@@ -208,6 +213,72 @@ export const AccountQuery = {
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getFreeTierBuildUsageAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<AccountFreeTierBuildUsageData> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountFreeTierBuildUsageQuery, AccountFreeTierBuildUsageQueryVariables>(
|
||||
gql`
|
||||
query AccountFreeTierBuildUsage($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
}
|
||||
billingPeriod(date: $currentDate) {
|
||||
id
|
||||
start
|
||||
end
|
||||
}
|
||||
usageMetrics {
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
planMetrics {
|
||||
id
|
||||
serviceMetric
|
||||
metricType
|
||||
value
|
||||
limit
|
||||
platformBreakdown {
|
||||
ios {
|
||||
value
|
||||
limit
|
||||
}
|
||||
android {
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: [
|
||||
'Account',
|
||||
'AccountUsageMetrics',
|
||||
'UsageMetricTotal',
|
||||
'BillingPeriod',
|
||||
'EstimatedUsage',
|
||||
],
|
||||
noRetry: true,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getUsageForOverageWarningAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
AppObserveNavigationRoute,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanListFilter,
|
||||
AppObserveSpanListOrderBy,
|
||||
AppObserveSpanName,
|
||||
AppObserveSpanNamesInput,
|
||||
AppObserveTrace,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListFilter,
|
||||
AppObserveUserEventListOrderBy,
|
||||
@@ -31,6 +37,7 @@ import {
|
||||
AppObserveErrorGroupFragmentNode,
|
||||
AppObserveErrorOccurrenceFragmentNode,
|
||||
AppObserveMetricFragmentNode,
|
||||
AppObserveSpanFragmentNode,
|
||||
AppObserveUserEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
|
||||
@@ -222,6 +229,10 @@ type AppObserveSessionEventsQuery = {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveSessionLog }>;
|
||||
};
|
||||
spans: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveSpan }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -234,6 +245,68 @@ type AppObserveSessionEventsQueryVariables = {
|
||||
first?: number;
|
||||
metricsOrderBy?: AppObserveMetricsListOrderBy;
|
||||
logsOrderBy?: AppObserveLogsOrderBy;
|
||||
spansOrderBy?: AppObserveSpanListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSpanListQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
spans: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveSpan;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveSpanListQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveSpanListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveSpanListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSpanNamesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
names: {
|
||||
isTruncated: boolean;
|
||||
names: AppObserveSpanName[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveSpanNamesQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveSpanNamesInput;
|
||||
};
|
||||
|
||||
type AppObserveTraceByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
trace: AppObserveTrace | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveMetricByIdQuery = {
|
||||
@@ -645,8 +718,10 @@ export const ObserveQuery = {
|
||||
): Promise<{
|
||||
metrics: AppObserveMetric[];
|
||||
logs: AppObserveSessionLog[];
|
||||
spans: AppObserveSpan[];
|
||||
metricsPageInfo: PageInfo;
|
||||
logsPageInfo: PageInfo;
|
||||
spansPageInfo: PageInfo;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
@@ -658,6 +733,7 @@ export const ObserveQuery = {
|
||||
$first: Int
|
||||
$metricsOrderBy: AppObserveMetricsListOrderBy
|
||||
$logsOrderBy: AppObserveLogsOrderBy
|
||||
$spansOrderBy: AppObserveSpanListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
@@ -697,6 +773,19 @@ export const ObserveQuery = {
|
||||
}
|
||||
}
|
||||
}
|
||||
spans(first: $first, orderBy: $spansOrderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,21 +794,150 @@ export const ObserveQuery = {
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
${print(AppObserveErrorFragmentNode)}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { metrics, logs } = data.app.byId.observe.session;
|
||||
const { metrics, logs, spans } = data.app.byId.observe.session;
|
||||
return {
|
||||
metrics: metrics.edges.map(edge => edge.node),
|
||||
logs: logs.edges.map(edge => edge.node),
|
||||
spans: spans.edges.map(edge => edge.node),
|
||||
metricsPageInfo: metrics.pageInfo,
|
||||
logsPageInfo: logs.pageInfo,
|
||||
spansPageInfo: spans.pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async spanListAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSpanListQueryVariables
|
||||
): Promise<{ spans: AppObserveSpan[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveSpanListQuery, AppObserveSpanListQueryVariables>(
|
||||
gql`
|
||||
query AppObserveSpanList(
|
||||
$appId: String!
|
||||
$filter: AppObserveSpanListFilter!
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveSpanListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
spans(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.traces.spans;
|
||||
return {
|
||||
spans: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async spanNamesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSpanNamesQueryVariables
|
||||
): Promise<{ names: AppObserveSpanName[]; isTruncated: boolean }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveSpanNamesQuery, AppObserveSpanNamesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveSpanNames($appId: String!, $input: AppObserveSpanNamesInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
names(input: $input) {
|
||||
isTruncated
|
||||
names {
|
||||
name
|
||||
kind
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.traces.names;
|
||||
},
|
||||
|
||||
async traceByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveTrace | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTraceByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTraceById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
trace(id: $id) {
|
||||
id
|
||||
isTruncated
|
||||
spans {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.traces.trace ?? null;
|
||||
},
|
||||
|
||||
async metricByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { UserPreferenceByKeyQuery, UserPreferenceByKeyQueryVariables } from '../generated';
|
||||
|
||||
export const UserPreferenceQuery = {
|
||||
/**
|
||||
* Returns the JSON value stored under the key for the current user, or null when the key is not
|
||||
* set or the viewer is not a user (for example a robot).
|
||||
*/
|
||||
async getValueAsync(graphqlClient: ExpoGraphqlClient, key: string): Promise<unknown> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<UserPreferenceByKeyQuery, UserPreferenceByKeyQueryVariables>(
|
||||
gql`
|
||||
query UserPreferenceByKey($key: String!) {
|
||||
userPreference {
|
||||
get(key: $key) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ key },
|
||||
{ additionalTypenames: ['UserPreference'], noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.userPreference.get?.value ?? null;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasServiceMetric, UsageMetricType } from '../../generated';
|
||||
import { AccountQuery } from '../AccountQuery';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
query: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
|
||||
}
|
||||
|
||||
describe('AccountQuery.getFreeTierBuildUsageAsync', () => {
|
||||
const currentDate = new Date('2026-10-26T12:00:00.000Z');
|
||||
const account = {
|
||||
id: 'account-id',
|
||||
subscription: { id: 'subscription-id', name: 'Free' },
|
||||
billingPeriod: {
|
||||
id: 'period-1',
|
||||
start: '2026-10-01T00:00:00.000Z',
|
||||
end: '2026-11-01T00:00:00.000Z',
|
||||
},
|
||||
usageMetrics: {
|
||||
EAS_BUILD: {
|
||||
id: 'eas-build',
|
||||
planMetrics: [
|
||||
{
|
||||
id: 'builds',
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: 14,
|
||||
limit: 30,
|
||||
platformBreakdown: {
|
||||
android: { value: 11, limit: 15 },
|
||||
ios: { value: 3, limit: 15 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('returns the account usage', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { account: { byId: account } } });
|
||||
|
||||
await expect(
|
||||
AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate)
|
||||
).resolves.toEqual(account);
|
||||
});
|
||||
|
||||
it('selects the plan name, billing period, and per-platform build usage for the date', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { account: { byId: account } } });
|
||||
|
||||
await AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate);
|
||||
|
||||
const [document, variables, context] = graphqlClient.query.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('query AccountFreeTierBuildUsage');
|
||||
expect(printed).toContain('byId(accountId: $accountId)');
|
||||
expect(printed).toContain('billingPeriod(date: $currentDate)');
|
||||
expect(printed).toContain('EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS)');
|
||||
expect(printed).toContain('platformBreakdown');
|
||||
expect(printed).not.toContain('overageMetrics');
|
||||
expect(variables).toEqual({
|
||||
accountId: 'account-id',
|
||||
currentDate: '2026-10-26T12:00:00.000Z',
|
||||
});
|
||||
expect(context).toEqual({
|
||||
additionalTypenames: [
|
||||
'Account',
|
||||
'AccountUsageMetrics',
|
||||
'UsageMetricTotal',
|
||||
'BillingPeriod',
|
||||
'EstimatedUsage',
|
||||
],
|
||||
noRetry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('offline') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(
|
||||
AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate)
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceQuery } from '../UserPreferenceQuery';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
query: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
|
||||
}
|
||||
|
||||
describe('UserPreferenceQuery.getValueAsync', () => {
|
||||
it('returns the stored value', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
userPreference: {
|
||||
get: { id: 'preference-id', key: 'some-key', value: { billingPeriodId: 'period-1' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).resolves.toEqual({
|
||||
billingPeriodId: 'period-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the key is not set', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { userPreference: { get: null } } });
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('queries the preference by key', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { userPreference: { get: null } } });
|
||||
|
||||
await UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key');
|
||||
|
||||
const [document, variables, context] = graphqlClient.query.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('query UserPreferenceByKey');
|
||||
expect(printed).toContain('get(key: $key)');
|
||||
expect(variables).toEqual({ key: 'some-key' });
|
||||
expect(context).toEqual({ additionalTypenames: ['UserPreference'], noRetry: true });
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('offline') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -170,3 +170,56 @@ export const AppObserveAppVersionFragmentNode = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveSpanFragmentNode = gql`
|
||||
fragment AppObserveSpanFragment on AppObserveSpan {
|
||||
id
|
||||
traceId
|
||||
spanId
|
||||
parentSpanId
|
||||
name
|
||||
kind
|
||||
startTimestamp
|
||||
endTimestamp
|
||||
durationMs
|
||||
statusCode
|
||||
statusMessage
|
||||
sessionId
|
||||
http {
|
||||
method
|
||||
statusCode
|
||||
urlHost
|
||||
urlFull
|
||||
protocolVersion
|
||||
requestSize
|
||||
responseSize
|
||||
errorType
|
||||
}
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
events {
|
||||
name
|
||||
timestamp
|
||||
attributes
|
||||
}
|
||||
droppedAttributesCount
|
||||
droppedEventsCount
|
||||
droppedLinksCount
|
||||
ingestedAt
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
appEasBuildId
|
||||
expoSdkVersion
|
||||
reactNativeVersion
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
deviceModel
|
||||
environment
|
||||
easClientId
|
||||
countryCode
|
||||
}
|
||||
`;
|
||||
@@ -22,6 +22,7 @@ export const WorkflowRunFragmentNode = gql`
|
||||
triggeringSchedule
|
||||
createdAt
|
||||
updatedAt
|
||||
finalizedAt
|
||||
errors {
|
||||
title
|
||||
message
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanListOrderByField,
|
||||
AppObserveSpanStatusCode,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListOrderByField,
|
||||
} from '../../graphql/generated';
|
||||
@@ -106,6 +110,54 @@ function makeErrorLog(overrides: Partial<AppObserveError> = {}): AppObserveSessi
|
||||
} as AppObserveSessionLog;
|
||||
}
|
||||
|
||||
function makeSpan(overrides: Partial<AppObserveSpan> = {}): AppObserveSpan {
|
||||
return {
|
||||
__typename: 'AppObserveSpan' as const,
|
||||
id: 'span-1',
|
||||
traceId: 'a'.repeat(32),
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
name: 'GET',
|
||||
kind: AppObserveSpanKind.Client,
|
||||
startTimestamp: '2025-01-15T10:00:01.000Z',
|
||||
endTimestamp: '2025-01-15T10:00:01.123Z',
|
||||
durationMs: 123.4,
|
||||
statusCode: AppObserveSpanStatusCode.Ok,
|
||||
statusMessage: null,
|
||||
sessionId: 'session-1',
|
||||
http: {
|
||||
__typename: 'AppObserveSpanHttp' as const,
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
protocolVersion: '1.1',
|
||||
requestSize: 100,
|
||||
responseSize: 2000,
|
||||
errorType: null,
|
||||
},
|
||||
attributes: [],
|
||||
events: [],
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 0,
|
||||
ingestedAt: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
expoSdkVersion: '54.0.0',
|
||||
reactNativeVersion: '0.81.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
...overrides,
|
||||
} as AppObserveSpan;
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
limit: 100,
|
||||
};
|
||||
@@ -118,6 +170,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +208,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -180,6 +236,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -202,6 +260,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [makeUserLog({ timestamp: '2025-01-15T10:05:00.000Z', appUpdateId: 'update-xyz' })],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -236,6 +296,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [],
|
||||
metricsPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -245,6 +307,52 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
|
||||
expect(result.hasMoreMetricEvents).toBe(true);
|
||||
expect(result.hasMoreLogEvents).toBe(false);
|
||||
expect(result.hasMoreSpans).toBe(false);
|
||||
});
|
||||
|
||||
it('queries the session spans oldest-first and merges them into the timeline', async () => {
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:00:00.000Z' })],
|
||||
logs: [makeUserLog({ timestamp: '2025-01-15T10:00:02.000Z' })],
|
||||
spans: [makeSpan({ startTimestamp: '2025-01-15T10:00:01.000Z' })],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spansPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cs' },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(mockSessionEventsAsync).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
spansOrderBy: {
|
||||
field: AppObserveSpanListOrderByField.StartTimestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.entries.map(e => e.source)).toEqual(['metric', 'span', 'log']);
|
||||
const spanEntry = result.entries[1];
|
||||
expect(spanEntry).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'span-1',
|
||||
name: 'GET',
|
||||
timestamp: '2025-01-15T10:00:01.000Z',
|
||||
traceId: 'a'.repeat(32),
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
durationMs: 123.4,
|
||||
statusCode: AppObserveSpanStatusCode.Ok,
|
||||
httpMethod: 'GET',
|
||||
httpStatusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
})
|
||||
);
|
||||
expect(result.hasMoreSpans).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user