mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d4262aa7 | ||
|
|
b22d89e41f | ||
|
|
81fe0f237f | ||
|
|
b9ddc04cf7 | ||
|
|
d6a8a4f694 | ||
|
|
7f163e3e52 | ||
|
|
f821cb176c | ||
|
|
3a70a5d9b8 | ||
|
|
4d74298bb7 | ||
|
|
3155fc26e7 | ||
|
|
0fb6dfc334 | ||
|
|
dd84646768 | ||
|
|
c3fbf63a71 | ||
|
|
7c0d7b60c7 | ||
|
|
5a8e1b4ba3 | ||
|
|
12b648a603 |
No files matched your search
+22
-2
@@ -8,18 +8,37 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🧹 Chores
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
@@ -27,6 +46,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.5.0",
|
||||
"version": "18.7.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.5.0",
|
||||
"version": "18.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
|
||||
@@ -18,7 +18,9 @@ import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
@@ -47,6 +49,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createEasBuildBuildFunctionGroup } from '../build';
|
||||
|
||||
function createMockBuildToolsContext(
|
||||
overrides: Partial<{
|
||||
platform: Platform;
|
||||
simulator: boolean;
|
||||
buildCredentials: Record<string, unknown>;
|
||||
}> = {}
|
||||
): CustomBuildContext<BuildJob> {
|
||||
return {
|
||||
job: {
|
||||
platform: overrides.platform ?? Platform.ANDROID,
|
||||
simulator: overrides.simulator ?? false,
|
||||
secrets: overrides.buildCredentials
|
||||
? { buildCredentials: overrides.buildCredentials }
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
}
|
||||
|
||||
describe(createEasBuildBuildFunctionGroup, () => {
|
||||
describe('working_directory input', () => {
|
||||
it('does not set working directory on steps when not provided (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
for (const step of steps) {
|
||||
// Without working_directory, only installPods would have a relative dir (iOS only).
|
||||
// On Android, no step should have a relative working directory.
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS simulator)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
|
||||
});
|
||||
|
||||
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for generic jobs (no platform)', () => {
|
||||
const buildToolsContext = {
|
||||
job: { platform: undefined },
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -199,11 +250,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -215,24 +271,29 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -242,6 +303,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -249,6 +311,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -257,6 +320,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -264,18 +328,26 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -286,24 +358,29 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -313,6 +390,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -320,6 +398,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -328,6 +407,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -335,21 +415,33 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -359,8 +451,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { createReadAppConfigBuildFunction } from '../readAppConfig';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
|
||||
describe(createReadAppConfigBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('reads and logs app configuration', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({
|
||||
logger,
|
||||
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
|
||||
});
|
||||
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
|
||||
logger: stepLogger,
|
||||
sdkVersion: '54.0.0',
|
||||
})
|
||||
);
|
||||
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
|
||||
expect(stepLogger.info).toHaveBeenCalledWith(
|
||||
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
|
||||
);
|
||||
const firstOutput = buildStep.outputById.app_config.value;
|
||||
expect(firstOutput).toBeDefined();
|
||||
expect(JSON.parse(firstOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 'my-app-slug',
|
||||
version: '1.2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if reading app config fails', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('exports app fields as returned by app config', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const secondOutput = buildStep.outputById.app_config.value;
|
||||
expect(secondOutput).toBeDefined();
|
||||
expect(JSON.parse(secondOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAndLogPackageJson } from '../../../utils/project';
|
||||
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
|
||||
|
||||
jest.mock('../../../utils/project');
|
||||
|
||||
describe(createReadPackageJsonBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('logs package.json contents', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAndLogPackageJson).toHaveBeenCalledWith(
|
||||
stepLogger,
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
expect(buildStep.outputById.package_json.value).toBe(
|
||||
JSON.stringify({ name: 'app', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if reading package.json fails', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
|
||||
throw new Error('failed to read package.json');
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
expect(stepLogger.error).toHaveBeenCalledWith({
|
||||
err: expect.any(Error),
|
||||
});
|
||||
expect(buildStep.outputById.package_json.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
@@ -212,4 +211,64 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails early on Linux when emulator host support check fails', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
|
||||
|
||||
const step = createStep();
|
||||
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
|
||||
});
|
||||
|
||||
it('continues startup on Linux when emulator host support check succeeds', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips emulator host support check when opt out env var is enabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: undefined,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: '/android/sdk/root',
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'/android/sdk/root/emulator/emulator',
|
||||
['-accel-check'],
|
||||
{
|
||||
env: expect.any(Object),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
|
||||
export function createReadAppConfigBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_app_config',
|
||||
name: 'Read app config',
|
||||
__metricsId: 'eas/read_app_config',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'app_config',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, outputs }) => {
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
stepCtx.logger.info('Using app configuration:');
|
||||
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
outputs.app_config.set(JSON.stringify(appConfig));
|
||||
} catch {
|
||||
// readAppConfig logs underlying failures.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAndLogPackageJson } from '../../utils/project';
|
||||
|
||||
export function createReadPackageJsonBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_package_json',
|
||||
name: 'Read package.json',
|
||||
__metricsId: 'eas/read_package_json',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'package_json',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
try {
|
||||
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
|
||||
outputs.package_json.set(JSON.stringify(packageJson));
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import {
|
||||
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
@@ -253,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
|
||||
if (!isAndroidEmulatorHostSupported) {
|
||||
throw new UserError(
|
||||
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
|
||||
'Could not start the Android emulator on this runner.\n\n' +
|
||||
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
|
||||
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
|
||||
' runs_on: linux-medium-nested-virtualization\n' +
|
||||
' runs_on: linux-large-nested-virtualization\n\n' +
|
||||
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
|
||||
try {
|
||||
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { PackageManager, findPackagerRootDir } from '../packageManager';
|
||||
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
|
||||
import {
|
||||
isUsingModernYarnVersion,
|
||||
readAndLogPackageJson,
|
||||
readEasJsonContents,
|
||||
runExpoCliCommand,
|
||||
} from '../project';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -148,3 +153,23 @@ describe(readEasJsonContents, () => {
|
||||
expect(readEasJsonContents(projectDir)).toBe(contents);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readAndLogPackageJson, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('reads package.json, logs it, and returns parsed contents', async () => {
|
||||
const projectDir = '/project';
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = { name: 'app', version: '1.0.0' };
|
||||
const logger = { info: jest.fn() };
|
||||
|
||||
await fs.mkdirp(projectDir);
|
||||
await fs.writeJson(packageJsonPath, packageJson);
|
||||
|
||||
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,16 @@ export function readPackageJson(projectDir: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
export function readAndLogPackageJson(
|
||||
logger: { info: (message: string) => void },
|
||||
projectDir: string
|
||||
): any {
|
||||
logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(projectDir);
|
||||
logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
export function readEasJsonContents(projectDir: string): string {
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
if (!fs.pathExistsSync(easJsonPath)) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["jest", "node"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gql.tada/ts-plugin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.5.0"
|
||||
"@expo/steps": "18.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -56,6 +56,110 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
cf_bundle_version: '42',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
cf_bundle_version: '42',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context without build_upload', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
@@ -84,6 +188,232 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with app_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'ready_for_review',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect app_version state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with external_beta', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'beta_approved',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect external_beta state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with beta_feedback', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'screenshot',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects app_store_connect beta_feedback without url', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'crash',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect beta_feedback type', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'invalid-type',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect build_upload context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -226,6 +226,72 @@ const GitHubContextZ = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const AppStoreConnectContextZ = z.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
app_version: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'accepted',
|
||||
'developer_rejected',
|
||||
'in_review',
|
||||
'invalid_binary',
|
||||
'metadata_rejected',
|
||||
'pending_apple_release',
|
||||
'pending_developer_release',
|
||||
'prepare_for_submission',
|
||||
'processing_for_distribution',
|
||||
'ready_for_distribution',
|
||||
'ready_for_review',
|
||||
'rejected',
|
||||
'replaced_with_new_version',
|
||||
'waiting_for_export_compliance',
|
||||
'waiting_for_review',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
cf_bundle_version: z.string().optional(),
|
||||
build: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
external_beta: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'processing',
|
||||
'processing_exception',
|
||||
'missing_export_compliance',
|
||||
'ready_for_beta_testing',
|
||||
'in_beta_testing',
|
||||
'expired',
|
||||
'ready_for_beta_submission',
|
||||
'in_export_compliance_review',
|
||||
'waiting_for_beta_review',
|
||||
'in_beta_review',
|
||||
'beta_rejected',
|
||||
'beta_approved',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
beta_feedback: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
type: z.enum(['crash', 'screenshot']),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -259,19 +325,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
app_store_connect: z
|
||||
.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
|
||||
app_store_connect: AppStoreConnectContextZ.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
+90
-89
@@ -178,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +422,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +442,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +460,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -488,7 +488,7 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
@@ -496,12 +496,13 @@ FLAGS
|
||||
build profile will be selected by default.
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
|
||||
--skip-bundler Install and run the development build without starting the bundler server.
|
||||
|
||||
DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,7 +523,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,7 +561,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,7 +601,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,7 +631,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,7 +657,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,7 +709,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,7 +727,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,7 +745,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,7 +762,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,7 +783,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,7 +804,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,7 +826,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,7 +846,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,7 +868,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,7 +890,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,7 +925,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,7 +948,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,7 +969,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +986,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,7 +1004,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,7 +1033,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,7 +1058,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,7 +1079,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,7 +1100,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,7 +1137,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,7 +1157,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,7 +1178,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,7 +1199,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,7 +1213,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1227,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,7 +1259,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,7 +1285,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,7 +1307,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,7 +1335,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1363,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,7 +1386,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,7 +1408,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,7 +1442,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,7 +1486,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,7 +1517,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,7 +1626,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1643,7 +1644,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1661,7 +1662,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1713,7 +1714,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1727,7 +1728,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1750,7 +1751,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1774,7 +1775,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1792,7 +1793,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1825,7 +1826,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1868,7 +1869,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1889,7 +1890,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1910,7 +1911,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1935,7 +1936,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1962,7 +1963,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2000,7 +2001,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2028,7 +2029,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2058,7 +2059,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2079,7 +2080,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2099,7 +2100,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2120,7 +2121,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2142,7 +2143,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2162,7 +2163,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2181,7 +2182,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2204,7 +2205,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2221,7 +2222,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2342,7 +2343,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2362,7 +2363,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2386,7 +2387,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2426,7 +2427,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2448,7 +2449,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2476,7 +2477,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2496,7 +2497,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2518,5 +2519,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+2868
-545
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.5.0",
|
||||
"version": "18.7.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-json": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-json": "18.6.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.5.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -204,6 +204,9 @@
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"observe": {
|
||||
"description": "monitor app performance metrics"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
|
||||
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
|
||||
export const getLimitFlagWithCustomValues = ({
|
||||
defaultTo,
|
||||
limit,
|
||||
description,
|
||||
}: {
|
||||
defaultTo: number;
|
||||
limit: number;
|
||||
description?: string;
|
||||
}): Interfaces.OptionFlag<number | undefined> =>
|
||||
Flags.integer({
|
||||
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
description:
|
||||
description ??
|
||||
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export default class BuildDev extends EasCommand {
|
||||
description: 'Skip build if no successful build with matching fingerprint is found.',
|
||||
default: false,
|
||||
}),
|
||||
'skip-bundler': Flags.boolean({
|
||||
description: 'Install and run the development build without starting the bundler server.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -129,7 +133,9 @@ export default class BuildDev extends EasCommand {
|
||||
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
await downloadAndRunAsync(build);
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
Log.warn('Artifacts for this build expired.');
|
||||
@@ -182,7 +188,9 @@ export default class BuildDev extends EasCommand {
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
});
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
}
|
||||
|
||||
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
|
||||
|
||||
@@ -362,8 +362,22 @@ export default class WorkerDeploy extends EasCommand {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
await finalizeDeployAsync(deployResult);
|
||||
let uploadError: unknown;
|
||||
try {
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
try {
|
||||
await finalizeDeployAsync(deployResult);
|
||||
} catch (finalizeError) {
|
||||
if (!uploadError) {
|
||||
throw finalizeError;
|
||||
}
|
||||
}
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
|
||||
if (flags.aliasName) {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
|
||||
import { buildObserveEventsJson } from '../../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
|
||||
jest.mock('../../../observe/fetchEvents', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchEvents');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveEventsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveEventsJson: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveEvents, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvents {
|
||||
const command = new ObserveEvents(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'tti',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --limit to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--limit', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('passes --after cursor to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--after', 'cursor-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.after).toBe('cursor-xyz');
|
||||
});
|
||||
|
||||
it('does not pass after when --after flag is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options).not.toHaveProperty('after');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
});
|
||||
|
||||
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('passes --app-version to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--app-version', '2.1.0']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --update-id to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['tti', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
|
||||
mockEvents,
|
||||
expect.objectContaining({ hasNextPage: false })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--sort', 'slowest']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when no metric is provided', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('resolves lowercase "slowest" to MetricValue DESC', () => {
|
||||
expect(resolveOrderBy('slowest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "fastest" to MetricValue ASC', () => {
|
||||
expect(resolveOrderBy('fastest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "newest" to Timestamp DESC', () => {
|
||||
expect(resolveOrderBy('newest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "oldest" to Timestamp ASC', () => {
|
||||
expect(resolveOrderBy('oldest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
|
||||
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveMetrics from '../metrics';
|
||||
|
||||
jest.mock('../../../observe/fetchMetrics', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchMetrics');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveMetricsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatMetrics', () => ({
|
||||
...jest.requireActual('../../../observe/formatMetrics'),
|
||||
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
|
||||
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
|
||||
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveMetrics, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveMetricsAsync.mockResolvedValue({
|
||||
metricsMap: new Map(),
|
||||
buildNumbersMap: new Map(),
|
||||
updateIdsMap: new Map(),
|
||||
totalEventCounts: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveMetrics {
|
||||
const command = new ObserveMetrics(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches metrics with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
|
||||
await command.runAsync();
|
||||
|
||||
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
|
||||
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
|
||||
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['p90', 'eventCount'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates --stat flags that resolve to the same key', async () => {
|
||||
const command = createCommand(['--stat', 'median', '--stat', 'median']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('uses default stats when --stat is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median', 'eventCount'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
|
||||
const command = createCommand([
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
'--stat',
|
||||
'min',
|
||||
'--stat',
|
||||
'average',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['min', 'average'],
|
||||
expect.any(Map),
|
||||
expect.any(Map),
|
||||
expect.any(Map)
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateDateFlag, () => {
|
||||
it('throws on invalid --start date', () => {
|
||||
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
|
||||
'Invalid --start date: "not-a-date"'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid --end date', () => {
|
||||
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
|
||||
});
|
||||
|
||||
it('accepts valid ISO date in --start', () => {
|
||||
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
|
||||
import {
|
||||
buildObserveVersionsJson,
|
||||
buildObserveVersionsTable,
|
||||
} from '../../../observe/formatVersions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveVersions from '../versions';
|
||||
|
||||
jest.mock('../../../observe/fetchVersions');
|
||||
jest.mock('../../../observe/formatVersions', () => ({
|
||||
buildObserveVersionsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveVersionsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveVersionsAsync = jest.mocked(fetchObserveVersionsAsync);
|
||||
const mockBuildObserveVersionsTable = jest.mocked(buildObserveVersionsTable);
|
||||
const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveVersions, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveVersions {
|
||||
const command = new ObserveVersions(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches versions with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes the context project ID to fetchObserveVersionsAsync by default', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('uses --project-id flag when provided', async () => {
|
||||
const command = createCommand(['--project-id', 'override-project-id']);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe('override-project-id');
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([
|
||||
{
|
||||
platform: AppPlatform.Ios,
|
||||
appVersions: [
|
||||
{
|
||||
appVersion: '1.0.0',
|
||||
firstSeenAt: '2025-06-01T00:00:00.000Z',
|
||||
eventCount: 100,
|
||||
uniqueUserCount: 50,
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveVersionsJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls buildObserveVersionsTable for non-json output', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveVersionsTable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EventsOrderPreset,
|
||||
fetchObserveEventsAsync,
|
||||
fetchTotalEventCountAsync,
|
||||
resolveOrderBy,
|
||||
} from '../../observe/fetchEvents';
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display individual app performance events ordered by metric value';
|
||||
|
||||
static override args = {
|
||||
metric: Args.string({
|
||||
description: 'Metric to query (e.g. tti, cold_launch)',
|
||||
required: false,
|
||||
options: Object.keys(METRIC_ALIASES),
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
sort: Flags.option({
|
||||
description: 'Sort order for events',
|
||||
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
|
||||
required: false,
|
||||
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_EVENTS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
let metricName: string;
|
||||
if (args.metric) {
|
||||
metricName = resolveMetricName(args.metric);
|
||||
} else if (flags['non-interactive']) {
|
||||
throw new EasCommandError(
|
||||
'A metric argument is required in non-interactive mode. Available metrics: ' +
|
||||
Object.keys(METRIC_ALIASES).join(', ')
|
||||
);
|
||||
} else {
|
||||
const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({
|
||||
title: `${displayName} (${fullName})`,
|
||||
value: fullName,
|
||||
}));
|
||||
metricName = await selectAsync('Select a metric', choices);
|
||||
}
|
||||
const orderBy = resolveOrderBy(flags.sort);
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = flags.platform
|
||||
? flags.platform === 'android'
|
||||
? AppObservePlatform.Android
|
||||
: AppObservePlatform.Ios
|
||||
: undefined;
|
||||
|
||||
const platforms: AppPlatform[] = platform
|
||||
? [platform === AppObservePlatform.Android ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
metricName,
|
||||
orderBy,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
}),
|
||||
fetchTotalEventCountAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricName,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
),
|
||||
]);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveEventsTable(events, pageInfo, {
|
||||
metricName,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
totalEventCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_METRICS = [
|
||||
'expo.app_startup.cold_launch_time',
|
||||
'expo.app_startup.warm_launch_time',
|
||||
'expo.app_startup.tti',
|
||||
'expo.app_startup.ttr',
|
||||
'expo.app_startup.bundle_load_time',
|
||||
];
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
export default class ObserveMetrics extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display app performance metrics grouped by app version';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
metric: Flags.option({
|
||||
description: 'Metric name to display (can be specified multiple times).',
|
||||
multiple: true,
|
||||
options: Object.keys(METRIC_ALIASES),
|
||||
})(),
|
||||
stat: Flags.option({
|
||||
description: 'Statistic to display per metric (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: DEFAULT_STATS_JSON,
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetrics);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const metricNames = flags.metric?.length
|
||||
? flags.metric.map(resolveMetricName)
|
||||
: DEFAULT_METRICS;
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
|
||||
await fetchObserveMetricsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
? Array.from(new Set(flags.stat.map(resolveStatKey)))
|
||||
: undefined;
|
||||
|
||||
if (flags.json) {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
metricNames,
|
||||
stats,
|
||||
totalEventCounts,
|
||||
buildNumbersMap,
|
||||
updateIdsMap
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveMetricsTable(metricsMap, metricNames, stats, {
|
||||
daysBack,
|
||||
buildNumbersMap,
|
||||
totalEventCounts,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveVersions extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display app versions with build and update details';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveVersions, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const { startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const results = await fetchObserveVersionsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveVersionsJson(results));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveVersionsTable(results));
|
||||
}
|
||||
}
|
||||
}
|
||||
+393
-70
@@ -120,6 +120,8 @@ export type Account = {
|
||||
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
|
||||
billing?: Maybe<Billing>;
|
||||
billingPeriod: BillingPeriod;
|
||||
/** Convex team connections for this account */
|
||||
convexTeamConnections: Array<ConvexTeamConnection>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
/** Echo projects for this account (paginated, most recent first) */
|
||||
@@ -155,6 +157,8 @@ export type Account = {
|
||||
name: Scalars['String']['output'];
|
||||
/** Offers set on this account */
|
||||
offers?: Maybe<Array<Offer>>;
|
||||
/** Onboarding milestone stats for this account */
|
||||
onboardingStats: AccountOnboardingStats;
|
||||
/**
|
||||
* Owning User of this account if personal account
|
||||
* @deprecated Deprecated in favor of ownerUserActor
|
||||
@@ -767,6 +771,15 @@ export type AccountNotificationPreferenceInput = {
|
||||
type: NotificationType;
|
||||
};
|
||||
|
||||
/** Onboarding milestone stats for an account */
|
||||
export type AccountOnboardingStats = {
|
||||
__typename?: 'AccountOnboardingStats';
|
||||
firstBuildCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
firstSubmissionCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
hasConfiguredUpdate: Scalars['Boolean']['output'];
|
||||
hasTeamMembers: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type AccountQuery = {
|
||||
__typename?: 'AccountQuery';
|
||||
/** Query an Account by ID */
|
||||
@@ -1116,7 +1129,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = {
|
||||
|
||||
|
||||
export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
androidAppCredentialsId: Scalars['String']['input'];
|
||||
credential: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1358,6 +1370,7 @@ export type App = Project & {
|
||||
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
|
||||
/** Android app credentials for the project */
|
||||
androidAppCredentials: Array<AndroidAppCredentials>;
|
||||
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
|
||||
/**
|
||||
* ios.appStoreUrl field from most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
@@ -1490,6 +1503,7 @@ export type App = Project & {
|
||||
timelineActivity: TimelineActivityConnection;
|
||||
/** @deprecated 'likes' have been deprecated. */
|
||||
trendScore: Scalars['Float']['output'];
|
||||
turtleBrownfieldArtifactsPaginated: BrownfieldArtifactsConnection;
|
||||
/** get an EAS branch owned by the app by name */
|
||||
updateBranchByName?: Maybe<UpdateBranch>;
|
||||
/** EAS branches owned by an app */
|
||||
@@ -1708,6 +1722,16 @@ export type AppTimelineActivityArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppTurtleBrownfieldArtifactsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<BrownfieldArtifactFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppUpdateBranchByNameArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
@@ -2050,14 +2074,19 @@ export type AppNotificationPreferenceInput = {
|
||||
|
||||
export type AppObserve = {
|
||||
__typename?: 'AppObserve';
|
||||
appVersions: Array<AppObserveAppVersion>;
|
||||
environments: Array<Scalars['String']['output']>;
|
||||
events: AppObserveEventsConnection;
|
||||
releases: Array<AppObserveRelease>;
|
||||
timeSeries: AppObserveTimeSeries;
|
||||
totalEventCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveAppVersionsArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveEnvironmentsArgs = {
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
@@ -2075,15 +2104,54 @@ export type AppObserveEventsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveReleasesArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveTimeSeriesArgs = {
|
||||
input: AppObserveTimeSeriesInput;
|
||||
};
|
||||
|
||||
export type AppObserveAppBuildNumber = {
|
||||
__typename?: 'AppObserveAppBuildNumber';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppEasBuild = {
|
||||
__typename?: 'AppObserveAppEasBuild';
|
||||
easBuildId: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppUpdate = {
|
||||
__typename?: 'AppObserveAppUpdate';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppVersion = {
|
||||
__typename?: 'AppObserveAppVersion';
|
||||
appVersion: Scalars['String']['output'];
|
||||
buildNumbers: Array<AppObserveAppBuildNumber>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
metrics: Array<AppObserveAppVersionMetric>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
updates: Array<AppObserveAppUpdate>;
|
||||
};
|
||||
|
||||
export type AppObserveAppVersionMetric = {
|
||||
__typename?: 'AppObserveAppVersionMetric';
|
||||
eventCount: Scalars['Int']['output'];
|
||||
metricName: Scalars['String']['output'];
|
||||
statistics: AppObserveVersionMarkerStatistics;
|
||||
};
|
||||
|
||||
export type AppObserveEvent = {
|
||||
__typename?: 'AppObserveEvent';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
@@ -2093,6 +2161,7 @@ export type AppObserveEvent = {
|
||||
appVersion: Scalars['String']['output'];
|
||||
clientVersion?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
customParams?: Maybe<Scalars['JSON']['output']>;
|
||||
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
|
||||
deviceModel: Scalars['String']['output'];
|
||||
deviceName?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2129,6 +2198,8 @@ export type AppObserveEventsConnection = {
|
||||
};
|
||||
|
||||
export type AppObserveEventsFilter = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2160,35 +2231,22 @@ export enum AppObservePlatform {
|
||||
Ios = 'IOS'
|
||||
}
|
||||
|
||||
export type AppObserveRelease = {
|
||||
__typename?: 'AppObserveRelease';
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
type: AppObserveReleaseType;
|
||||
};
|
||||
|
||||
export enum AppObserveReleaseType {
|
||||
Update = 'UPDATE',
|
||||
Version = 'VERSION'
|
||||
}
|
||||
|
||||
export type AppObserveReleasesInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
platform: AppObservePlatform;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeries = {
|
||||
__typename?: 'AppObserveTimeSeries';
|
||||
appVersionMarkers: Array<AppObserveAppVersion>;
|
||||
buckets: Array<AppObserveTimeSeriesBucket>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
latestVersionStatistics?: Maybe<AppObserveVersionStatistics>;
|
||||
previousVersionStatistics?: Maybe<AppObserveVersionStatistics>;
|
||||
statistics: AppObserveTimeSeriesStatistics;
|
||||
updateMarkers: Array<AppObserveUpdateMarker>;
|
||||
versionMarkers: Array<AppObserveVersionMarker>;
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeriesBucket = {
|
||||
@@ -2205,6 +2263,8 @@ export type AppObserveTimeSeriesBucket = {
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeriesInput = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -2227,22 +2287,6 @@ export type AppObserveTimeSeriesStatistics = {
|
||||
p99?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export type AppObserveUpdateMarker = {
|
||||
__typename?: 'AppObserveUpdateMarker';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
appVersion: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveVersionMarker = {
|
||||
__typename?: 'AppObserveVersionMarker';
|
||||
appVersion: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
statistics: AppObserveVersionMarkerStatistics;
|
||||
};
|
||||
|
||||
export type AppObserveVersionMarkerStatistics = {
|
||||
__typename?: 'AppObserveVersionMarkerStatistics';
|
||||
average?: Maybe<Scalars['Float']['output']>;
|
||||
@@ -2376,10 +2420,16 @@ export type AppStoreConnectApiKey = {
|
||||
keyIdentifier: Scalars['String']['output'];
|
||||
keyP8: Scalars['String']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
remoteAppStoreConnectApps: Array<RemoteAppStoreConnectApp>;
|
||||
roles?: Maybe<Array<AppStoreConnectUserRole>>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectApiKeyRemoteAppStoreConnectAppsArgs = {
|
||||
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AppStoreConnectApiKeyInput = {
|
||||
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
|
||||
issuerIdentifier: Scalars['String']['input'];
|
||||
@@ -2430,6 +2480,43 @@ export type AppStoreConnectApiKeyUpdateInput = {
|
||||
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type AppStoreConnectApp = {
|
||||
__typename?: 'AppStoreConnectApp';
|
||||
app: App;
|
||||
appStoreConnectApiKey: AppStoreConnectApiKey;
|
||||
ascAppIdentifier: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
remoteAppStoreConnectApp: RemoteAppStoreConnectApp;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
webhookEventTypes: Array<Scalars['String']['output']>;
|
||||
webhookIdentifier: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type AppStoreConnectAppInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
appStoreConnectApiKeyId: Scalars['ID']['input'];
|
||||
ascAppIdentifier: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppStoreConnectAppMutation = {
|
||||
__typename?: 'AppStoreConnectAppMutation';
|
||||
/** Create an App Store Connect app for an Expo app. */
|
||||
createAppStoreConnectApp: AppStoreConnectApp;
|
||||
/** Delete an App Store Connect app by ID. */
|
||||
deleteAppStoreConnectApp: DeleteAppStoreConnectAppResult;
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectAppMutationCreateAppStoreConnectAppArgs = {
|
||||
appStoreConnectAppInput: AppStoreConnectAppInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = {
|
||||
appStoreConnectAppId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum AppStoreConnectUserRole {
|
||||
AccessToReports = 'ACCESS_TO_REPORTS',
|
||||
AccountHolder = 'ACCOUNT_HOLDER',
|
||||
@@ -3278,6 +3365,36 @@ export type BranchQueryByIdArgs = {
|
||||
branchId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type BrownfieldArtifact = {
|
||||
__typename?: 'BrownfieldArtifact';
|
||||
bundleName: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
platform: AppPlatform;
|
||||
sizeBytes?: Maybe<Scalars['Int']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactEdge = {
|
||||
__typename?: 'BrownfieldArtifactEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: BrownfieldArtifact;
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactFilterInput = {
|
||||
bundleName?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
version?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactsConnection = {
|
||||
__typename?: 'BrownfieldArtifactsConnection';
|
||||
edges: Array<BrownfieldArtifactEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
/** Represents an EAS Build */
|
||||
export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
__typename?: 'Build';
|
||||
@@ -3921,13 +4038,6 @@ export type CodeSigningInfoInput = {
|
||||
sig: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
/** Metadata to set when completing a message. */
|
||||
export type CompleteEchoMessageMetadataInput = {
|
||||
cost?: InputMaybe<Scalars['Float']['input']>;
|
||||
finishReason?: InputMaybe<Scalars['String']['input']>;
|
||||
tokens?: InputMaybe<EchoMessageTokenUsageInput>;
|
||||
};
|
||||
|
||||
export type Concurrencies = {
|
||||
__typename?: 'Concurrencies';
|
||||
android: Scalars['Int']['output'];
|
||||
@@ -3951,6 +4061,31 @@ export type ConvexIntegrationQuery = {
|
||||
clientIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConvexTeamConnection = {
|
||||
__typename?: 'ConvexTeamConnection';
|
||||
account: Account;
|
||||
convexTeamIdentifier: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type ConvexTeamConnectionMutation = {
|
||||
__typename?: 'ConvexTeamConnectionMutation';
|
||||
createConvexTeamConnection: ConvexTeamConnection;
|
||||
deleteConvexTeamConnection: ConvexTeamConnection;
|
||||
};
|
||||
|
||||
|
||||
export type ConvexTeamConnectionMutationCreateConvexTeamConnectionArgs = {
|
||||
convexTeamConnectionData: CreateConvexTeamConnectionInput;
|
||||
};
|
||||
|
||||
|
||||
export type ConvexTeamConnectionMutationDeleteConvexTeamConnectionArgs = {
|
||||
convexTeamConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum CrashSampleFor {
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST'
|
||||
@@ -3986,12 +4121,41 @@ export type CreateAndroidSubmissionInput = {
|
||||
submittedBuildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type CreateBrownfieldArtifactInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
bundleName: Scalars['String']['input'];
|
||||
/** Filename for the artifact (e.g. "MyFramework.xcframework.tar.gz"). */
|
||||
filename: Scalars['String']['input'];
|
||||
platform: AppPlatform;
|
||||
/** The ID of the turtle job run that is producing this artifact. */
|
||||
producingTurtleJobRunId: Scalars['ID']['input'];
|
||||
/** Size of the artifact in bytes. Required for generating the signed upload URL. */
|
||||
size: Scalars['Int']['input'];
|
||||
/** Optional. If omitted, the version is auto-incremented from the latest artifact for the same platform and bundle name. */
|
||||
version?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type CreateBrownfieldArtifactResult = {
|
||||
__typename?: 'CreateBrownfieldArtifactResult';
|
||||
artifact: BrownfieldArtifact;
|
||||
/** Headers to include with the upload request. */
|
||||
uploadHeaders: Scalars['JSONObject']['output'];
|
||||
/** Signed URL to upload the artifact to. */
|
||||
uploadUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type CreateBuildResult = {
|
||||
__typename?: 'CreateBuildResult';
|
||||
build: Build;
|
||||
deprecationInfo?: Maybe<EasBuildDeprecationInfo>;
|
||||
};
|
||||
|
||||
export type CreateConvexTeamConnectionInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
convexTeamName?: InputMaybe<Scalars['String']['input']>;
|
||||
deploymentRegion: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateEchoChatInput = {
|
||||
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
agentType?: InputMaybe<EchoAgentType>;
|
||||
@@ -4002,10 +4166,12 @@ export type CreateEchoChatInput = {
|
||||
|
||||
export type CreateEchoMessageInput = {
|
||||
echoChatId: Scalars['ID']['input'];
|
||||
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
id?: InputMaybe<Scalars['ID']['input']>;
|
||||
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
parentEchoMessageId?: InputMaybe<Scalars['ID']['input']>;
|
||||
role: EchoMessageRole;
|
||||
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
|
||||
turnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
userId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
@@ -4056,6 +4222,7 @@ export type CreateEchoVersionInput = {
|
||||
diffs: Scalars['JSON']['input'];
|
||||
echoChatId?: InputMaybe<Scalars['ID']['input']>;
|
||||
echoProjectId: Scalars['ID']['input'];
|
||||
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
gitBranch?: InputMaybe<Scalars['String']['input']>;
|
||||
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
|
||||
gitCommitMessage?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -4063,6 +4230,7 @@ export type CreateEchoVersionInput = {
|
||||
revertedFromEchoVersionId?: InputMaybe<Scalars['ID']['input']>;
|
||||
source: EchoVersionSource;
|
||||
thumbnail?: InputMaybe<EchoVersionThumbnailInput>;
|
||||
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
|
||||
turnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
@@ -4312,6 +4480,11 @@ export type DeleteAndroidKeystoreResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteAppStoreConnectAppResult = {
|
||||
__typename?: 'DeleteAppStoreConnectAppResult';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteAppleDeviceResult = {
|
||||
__typename?: 'DeleteAppleDeviceResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -4394,6 +4567,11 @@ export type DeleteSsoUserResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteSentryInstallationResult = {
|
||||
__typename?: 'DeleteSentryInstallationResult';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteSentryProjectResult = {
|
||||
__typename?: 'DeleteSentryProjectResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -4621,6 +4799,7 @@ export enum EasBuildWaiverType {
|
||||
}
|
||||
|
||||
export enum EasService {
|
||||
Agent = 'AGENT',
|
||||
Builds = 'BUILDS',
|
||||
Jobs = 'JOBS',
|
||||
Mcp = 'MCP',
|
||||
@@ -4631,6 +4810,7 @@ export enum EasServiceMetric {
|
||||
AssetsRequests = 'ASSETS_REQUESTS',
|
||||
BandwidthUsage = 'BANDWIDTH_USAGE',
|
||||
Builds = 'BUILDS',
|
||||
CreditUsage = 'CREDIT_USAGE',
|
||||
LocalBuilds = 'LOCAL_BUILDS',
|
||||
ManifestRequests = 'MANIFEST_REQUESTS',
|
||||
McpRequests = 'MCP_REQUESTS',
|
||||
@@ -4755,24 +4935,19 @@ export type EchoMessage = {
|
||||
echoChat: EchoChat;
|
||||
/** Message parts (text, tool calls, etc.) ordered by index */
|
||||
echoMessageParts: Array<EchoMessagePart>;
|
||||
/** Turn entity for grouping user message + assistant response */
|
||||
echoTurn?: Maybe<EchoTurn>;
|
||||
id: Scalars['ID']['output'];
|
||||
metadata?: Maybe<Scalars['JSONObject']['output']>;
|
||||
/** Parent message (for conversation branching) */
|
||||
parentEchoMessage?: Maybe<EchoMessage>;
|
||||
role: EchoMessageRole;
|
||||
/** Turn ID for grouping user message + assistant response (UUID) */
|
||||
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
|
||||
turnId?: Maybe<Scalars['ID']['output']>;
|
||||
/** User who sent the message (for user messages) */
|
||||
user?: Maybe<User>;
|
||||
};
|
||||
|
||||
/** Breakdown of cache write input tokens by cache TTL. */
|
||||
export type EchoMessageCacheWriteInput = {
|
||||
ttl1h?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl5m?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl24h?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoMessageConnection = {
|
||||
__typename?: 'EchoMessageConnection';
|
||||
edges: Array<EchoMessageEdge>;
|
||||
@@ -4788,7 +4963,10 @@ export type EchoMessageEdge = {
|
||||
|
||||
export type EchoMessageMutation = {
|
||||
__typename?: 'EchoMessageMutation';
|
||||
/** Mark a message as completed (sets completedAt and optionally updates metadata with tokens) */
|
||||
/**
|
||||
* Mark a message as completed (sets completedAt).
|
||||
* metadata is accepted for backward compatibility but ignored — use echoTurn.completeTurn instead.
|
||||
*/
|
||||
completeMessage: EchoMessage;
|
||||
/** Create a new message */
|
||||
createMessage: EchoMessage;
|
||||
@@ -4797,7 +4975,7 @@ export type EchoMessageMutation = {
|
||||
|
||||
export type EchoMessageMutationCompleteMessageArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
metadata?: InputMaybe<CompleteEchoMessageMetadataInput>;
|
||||
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -4888,16 +5066,6 @@ export enum EchoMessageRole {
|
||||
User = 'USER'
|
||||
}
|
||||
|
||||
/** Token usage for billing. Tracks input/output tokens and cache usage. */
|
||||
export type EchoMessageTokenUsageInput = {
|
||||
cacheRead?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWriteBreakdown?: InputMaybe<EchoMessageCacheWriteInput>;
|
||||
input: Scalars['Int']['input'];
|
||||
output: Scalars['Int']['input'];
|
||||
reasoning?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoProject = {
|
||||
__typename?: 'EchoProject';
|
||||
account: Account;
|
||||
@@ -5119,6 +5287,57 @@ export type EchoRepositoryResult = {
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EchoTurn = {
|
||||
__typename?: 'EchoTurn';
|
||||
completedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Parent chat */
|
||||
echoChat: EchoChat;
|
||||
/** Messages in this turn */
|
||||
echoMessages: Array<EchoMessage>;
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
/** Breakdown of cache write input tokens by cache TTL. */
|
||||
export type EchoTurnCacheWriteInput = {
|
||||
ttl1h?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl5m?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl24h?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoTurnMutation = {
|
||||
__typename?: 'EchoTurnMutation';
|
||||
/** Mark a turn as completed and create a billing ledger entry */
|
||||
completeTurn: EchoTurn;
|
||||
};
|
||||
|
||||
|
||||
export type EchoTurnMutationCompleteTurnArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
usage: EchoTurnUsageInput;
|
||||
};
|
||||
|
||||
/** Token usage for billing. Tracks input/output tokens and cache usage. */
|
||||
export type EchoTurnTokenUsageInput = {
|
||||
cacheRead?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWriteBreakdown?: InputMaybe<EchoTurnCacheWriteInput>;
|
||||
input: Scalars['Int']['input'];
|
||||
output: Scalars['Int']['input'];
|
||||
reasoning?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
/** Cumulative usage data for a completed turn. */
|
||||
export type EchoTurnUsageInput = {
|
||||
agent?: InputMaybe<Scalars['String']['input']>;
|
||||
cost?: InputMaybe<Scalars['Float']['input']>;
|
||||
finishReason?: InputMaybe<Scalars['String']['input']>;
|
||||
model?: InputMaybe<Scalars['String']['input']>;
|
||||
provider?: InputMaybe<Scalars['String']['input']>;
|
||||
sdkCost?: InputMaybe<Scalars['Float']['input']>;
|
||||
tokens?: InputMaybe<EchoTurnTokenUsageInput>;
|
||||
};
|
||||
|
||||
export type EchoVersion = {
|
||||
__typename?: 'EchoVersion';
|
||||
buildError?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5128,6 +5347,8 @@ export type EchoVersion = {
|
||||
echoChat?: Maybe<EchoChat>;
|
||||
/** Parent project */
|
||||
echoProject: EchoProject;
|
||||
/** Turn that created this version */
|
||||
echoTurn?: Maybe<EchoTurn>;
|
||||
gitBranch: Scalars['String']['output'];
|
||||
gitCommitHash?: Maybe<Scalars['String']['output']>;
|
||||
gitCommitMessage?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5137,7 +5358,7 @@ export type EchoVersion = {
|
||||
revertedFromEchoVersion?: Maybe<EchoVersion>;
|
||||
source: EchoVersionSource;
|
||||
thumbnail?: Maybe<EchoVersionThumbnail>;
|
||||
/** Turn ID that created this version (UUID, optional) */
|
||||
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
|
||||
turnId?: Maybe<Scalars['ID']['output']>;
|
||||
};
|
||||
|
||||
@@ -5223,7 +5444,7 @@ export enum EntityTypeName {
|
||||
AndroidKeystoreEntity = 'AndroidKeystoreEntity',
|
||||
AppEntity = 'AppEntity',
|
||||
AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity',
|
||||
AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity',
|
||||
AppStoreConnectAppEntity = 'AppStoreConnectAppEntity',
|
||||
AppleDeviceEntity = 'AppleDeviceEntity',
|
||||
AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity',
|
||||
AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity',
|
||||
@@ -5880,6 +6101,7 @@ export type GitHubRepository = {
|
||||
githubRepositoryIdentifier: Scalars['Int']['output'];
|
||||
githubRepositoryUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
metadata: GitHubRepositoryMetadata;
|
||||
nodeIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
@@ -5903,8 +6125,8 @@ export type GitHubRepositoryMutation = {
|
||||
createAndConfigureRepository: BackgroundJobReceipt;
|
||||
/** Create a GitHub repository for an App */
|
||||
createGitHubRepository: GitHubRepository;
|
||||
/** Delete a GitHub repository by ID */
|
||||
deleteGitHubRepository: GitHubRepository;
|
||||
/** Delete a GitHub repository by ID in the background */
|
||||
scheduleGitHubRepositoryDeletion: BackgroundJobReceipt;
|
||||
};
|
||||
|
||||
|
||||
@@ -5923,7 +6145,7 @@ export type GitHubRepositoryMutationCreateGitHubRepositoryArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = {
|
||||
export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = {
|
||||
githubRepositoryId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
@@ -7091,6 +7313,14 @@ export type PublishUpdateGroupInput = {
|
||||
updateInfoGroup?: InputMaybe<UpdateInfoGroup>;
|
||||
};
|
||||
|
||||
export type RemoteAppStoreConnectApp = {
|
||||
__typename?: 'RemoteAppStoreConnectApp';
|
||||
appStoreIconUrl?: Maybe<Scalars['String']['output']>;
|
||||
ascAppIdentifier: Scalars['String']['output'];
|
||||
bundleIdentifier: Scalars['String']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum RequestMethod {
|
||||
Delete = 'DELETE',
|
||||
Get = 'GET',
|
||||
@@ -7270,6 +7500,8 @@ export type RootMutation = {
|
||||
app?: Maybe<AppMutation>;
|
||||
/** Mutations that modify an App Store Connect Api Key */
|
||||
appStoreConnectApiKey: AppStoreConnectApiKeyMutation;
|
||||
/** Mutations for App Store Connect apps. */
|
||||
appStoreConnectApp: AppStoreConnectAppMutation;
|
||||
/** Mutations that modify an AppVersion */
|
||||
appVersion: AppVersionMutation;
|
||||
/** Mutations that modify an Identifier for an iOS App */
|
||||
@@ -7292,6 +7524,7 @@ export type RootMutation = {
|
||||
build: BuildMutation;
|
||||
/** Mutations that create, update, and delete Build Annotations */
|
||||
buildAnnotation: BuildAnnotationMutation;
|
||||
convexTeamConnection: ConvexTeamConnectionMutation;
|
||||
customDomain: CustomDomainMutation;
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
@@ -7308,6 +7541,8 @@ export type RootMutation = {
|
||||
echoProject: EchoProjectMutation;
|
||||
/** Mutations for Echo repository management via the GitHub App */
|
||||
echoRepository: EchoRepositoryMutation;
|
||||
/** Mutations for Echo turns */
|
||||
echoTurn: EchoTurnMutation;
|
||||
/** Mutations for Echo versions */
|
||||
echoVersion: EchoVersionMutation;
|
||||
/** Mutations that create and delete EnvironmentSecrets */
|
||||
@@ -7354,6 +7589,7 @@ export type RootMutation = {
|
||||
sentryProject: SentryProjectMutation;
|
||||
/** Mutations that modify an EAS Submit submission */
|
||||
submission: SubmissionMutation;
|
||||
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
|
||||
update: UpdateMutation;
|
||||
updateBranch: UpdateBranchMutation;
|
||||
updateChannel: UpdateChannelMutation;
|
||||
@@ -7493,6 +7729,7 @@ export type RootQuery = {
|
||||
/** Top-level query object for querying Expo status page services. */
|
||||
statuspageService: StatuspageServiceQuery;
|
||||
submissions: SubmissionQuery;
|
||||
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactQuery;
|
||||
/** Top-level query object for querying Updates. */
|
||||
updates: UpdateQuery;
|
||||
/** fetch all updates in a group */
|
||||
@@ -7705,6 +7942,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
industry?: Maybe<Scalars['String']['output']>;
|
||||
isExpoAdmin: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -7817,6 +8055,8 @@ export type SentryInstallationMutation = {
|
||||
__typename?: 'SentryInstallationMutation';
|
||||
/** Confirm a pending Sentry installation */
|
||||
confirmPendingSentryInstallation: SentryInstallation;
|
||||
/** Force delete a Sentry installation from Expo's side */
|
||||
deleteSentryInstallation: DeleteSentryInstallationResult;
|
||||
/** Generate a Sentry token for an installation */
|
||||
generateSentryToken: GenerateSentryTokenResult;
|
||||
/** Link a Sentry installation to an Expo account */
|
||||
@@ -7829,6 +8069,11 @@ export type SentryInstallationMutationConfirmPendingSentryInstallationArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SentryInstallationMutationDeleteSentryInstallationArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SentryInstallationMutationGenerateSentryTokenArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -7867,6 +8112,13 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
|
||||
sentryProjectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SizeBreakdownCategory = {
|
||||
__typename?: 'SizeBreakdownCategory';
|
||||
assetCount: Scalars['Int']['output'];
|
||||
category: Scalars['String']['output'];
|
||||
totalBytes: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type Snack = Project & {
|
||||
__typename?: 'Snack';
|
||||
/** Description of the Snack */
|
||||
@@ -8226,6 +8478,34 @@ export type TimelineActivityFilterInput = {
|
||||
types?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
|
||||
};
|
||||
|
||||
export type TurtleBrownfieldArtifactMutation = {
|
||||
__typename?: 'TurtleBrownfieldArtifactMutation';
|
||||
createTurtleBrownfieldArtifact: CreateBrownfieldArtifactResult;
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactMutationCreateTurtleBrownfieldArtifactArgs = {
|
||||
input: CreateBrownfieldArtifactInput;
|
||||
};
|
||||
|
||||
export type TurtleBrownfieldArtifactQuery = {
|
||||
__typename?: 'TurtleBrownfieldArtifactQuery';
|
||||
byId: BrownfieldArtifact;
|
||||
latestForApp?: Maybe<BrownfieldArtifact>;
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactQueryByIdArgs = {
|
||||
turtleBrownfieldArtifactId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactQueryLatestForAppArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
bundleName: Scalars['String']['input'];
|
||||
platform: AppPlatform;
|
||||
};
|
||||
|
||||
export type UniqueUsersOverTimeData = {
|
||||
__typename?: 'UniqueUsersOverTimeData';
|
||||
data: LineChartData;
|
||||
@@ -8520,8 +8800,10 @@ export type UpdateDeploymentsConnection = {
|
||||
export type UpdateDiffReceipt = {
|
||||
__typename?: 'UpdateDiffReceipt';
|
||||
appId: Scalars['ID']['output'];
|
||||
baseUpdate?: Maybe<Update>;
|
||||
baseUpdateId: Scalars['ID']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
errorCode?: Maybe<Scalars['String']['output']>;
|
||||
errorMessage?: Maybe<Scalars['String']['output']>;
|
||||
fileSize?: Maybe<Scalars['Int']['output']>;
|
||||
@@ -8674,6 +8956,7 @@ export type UpdateInsights = {
|
||||
cumulativeAverageMetrics: CumulativeAverageMetrics;
|
||||
cumulativeMetrics: CumulativeMetrics;
|
||||
id: Scalars['ID']['output'];
|
||||
sizeBreakdownByCategory: Array<SizeBreakdownCategory>;
|
||||
totalUniqueUsers: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
@@ -8839,6 +9122,7 @@ export type UsageMetricTotal = {
|
||||
export enum UsageMetricType {
|
||||
Bandwidth = 'BANDWIDTH',
|
||||
Build = 'BUILD',
|
||||
Credit = 'CREDIT',
|
||||
Minute = 'MINUTE',
|
||||
Request = 'REQUEST',
|
||||
Update = 'UPDATE',
|
||||
@@ -8903,6 +9187,7 @@ export type User = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
isLegacy: Scalars['Boolean']['output'];
|
||||
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -9010,6 +9295,7 @@ export type UserActor = {
|
||||
/** @deprecated No longer supported */
|
||||
industry?: Maybe<Scalars['String']['output']>;
|
||||
isExpoAdmin: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -10587,6 +10873,10 @@ export type WorkflowRunTimeRangeInput = {
|
||||
};
|
||||
|
||||
export enum WorkflowRunTriggerEventType {
|
||||
AppStoreConnectAppVersionStateChanged = 'APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED',
|
||||
AppStoreConnectBetaFeedbackSubmitted = 'APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED',
|
||||
AppStoreConnectBuildUploadStateChanged = 'APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED',
|
||||
AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED',
|
||||
EasSubmit = 'EAS_SUBMIT',
|
||||
ExpoLaunch = 'EXPO_LAUNCH',
|
||||
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
|
||||
@@ -11791,6 +12081,33 @@ export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{
|
||||
|
||||
export type GoogleServiceAccountKeyByIdQuery = { __typename?: 'RootQuery', googleServiceAccountKey: { __typename?: 'GoogleServiceAccountKeyQuery', byId: { __typename?: 'GoogleServiceAccountKey', id: string, keyJson: string } } };
|
||||
|
||||
export type AppObserveTimeSeriesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveTimeSeriesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveTimeSeriesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', timeSeries: { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __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 } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } } } } } };
|
||||
|
||||
export type AppObserveAppVersionsQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveReleasesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveAppVersionsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', appVersions: Array<{ __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 AppObserveEventsQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter?: InputMaybe<AppObserveEventsFilter>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveEventsOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -11971,6 +12288,12 @@ export type EnvironmentVariableWithSecretFragment = { __typename?: 'EnvironmentV
|
||||
|
||||
export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } };
|
||||
|
||||
export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __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 } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } };
|
||||
|
||||
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string };
|
||||
|
||||
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 RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
|
||||
|
||||
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveTimeSeriesInput,
|
||||
AppObserveTimeSeriesStatistics,
|
||||
PageInfo,
|
||||
} from '../generated';
|
||||
import { print } from 'graphql';
|
||||
import {
|
||||
AppObserveAppVersionFragmentNode,
|
||||
AppObserveTimeSeriesFragmentNode,
|
||||
AppObserveEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
|
||||
export type AppObserveTimeSeriesResult = {
|
||||
appVersionMarkers: AppObserveAppVersion[];
|
||||
eventCount: number;
|
||||
statistics: AppObserveTimeSeriesStatistics;
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
timeSeries: AppObserveTimeSeriesResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQueryVariables = {
|
||||
appId: string;
|
||||
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
|
||||
};
|
||||
|
||||
type AppObserveAppVersionsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
appVersions: AppObserveAppVersion[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveAppVersionsQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
type AppObserveEventsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
events: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveEvent;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveEventsQueryVariables = {
|
||||
appId: string;
|
||||
filter?: AppObserveEventsFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveEventsOrderBy;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
metricName,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
appId: string;
|
||||
metricName: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
): Promise<AppObserveTimeSeriesResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTimeSeries(
|
||||
$appId: String!
|
||||
$input: AppObserveTimeSeriesInput!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
timeSeries(input: $input) {
|
||||
...AppObserveTimeSeriesFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveAppVersionFragmentNode)}
|
||||
${print(AppObserveTimeSeriesFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { metricName, platform, startTime, endTime },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.timeSeries;
|
||||
},
|
||||
|
||||
async appVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
}: {
|
||||
appId: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
metricNames?: string[];
|
||||
}
|
||||
): Promise<AppObserveAppVersion[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveAppVersions(
|
||||
$appId: String!
|
||||
$input: AppObserveReleasesInput!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
appVersions(input: $input) {
|
||||
...AppObserveAppVersionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveAppVersionFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.appVersions;
|
||||
},
|
||||
|
||||
async eventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveEventsQueryVariables
|
||||
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveEvents(
|
||||
$appId: String!
|
||||
$filter: AppObserveEventsFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveEventsOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
events(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveEventFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveEventFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.events;
|
||||
return {
|
||||
events: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const AppObserveTimeSeriesFragmentNode = gql`
|
||||
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
|
||||
appVersionMarkers {
|
||||
...AppObserveAppVersionFragment
|
||||
}
|
||||
eventCount
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveEventFragmentNode = gql`
|
||||
fragment AppObserveEventFragment on AppObserveEvent {
|
||||
id
|
||||
metricName
|
||||
metricValue
|
||||
timestamp
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
deviceModel
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
countryCode
|
||||
sessionId
|
||||
easClientId
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveAppVersionFragmentNode = gql`
|
||||
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
|
||||
appVersion
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
buildNumbers {
|
||||
appBuildNumber
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
easBuilds {
|
||||
easBuildId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
}
|
||||
}
|
||||
updates {
|
||||
appUpdateId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
easBuilds {
|
||||
easBuildId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
}
|
||||
}
|
||||
metrics {
|
||||
metricName
|
||||
eventCount
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -5,4 +5,4 @@ import { getAdapter } from 'axios';
|
||||
// see: https://github.com/nock/nock#axios
|
||||
|
||||
export { default } from 'nock';
|
||||
getRequestClient().defaults.adapter = getAdapter('http');
|
||||
getRequestClient().defaults.adapter = getAdapter('http') as any;
|
||||
@@ -443,5 +443,54 @@ describe(PreviewsTask, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes remote previews when preview type is removed from config', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
// No previews configured — the IPHONE_67 preview should be deleted
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const existingPreview = new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const deleteScope = nock('https://api.appstoreconnect.apple.com')
|
||||
.delete(`/v1/${AppPreview.type}/PV_1`)
|
||||
.reply(204);
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [existingPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(deleteScope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,9 +136,7 @@ export class PreviewsTask extends AppleTask {
|
||||
|
||||
for (const localeCode of locales) {
|
||||
const previews = config.getPreviews(localeCode);
|
||||
if (!previews || Object.keys(previews).length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existingSets = context.previewSets.get(localeCode);
|
||||
|
||||
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
|
||||
if (!localization) {
|
||||
@@ -146,18 +144,39 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
// Upload/sync configured previews
|
||||
if (previews) {
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
context.previewSets.get(localeCode)
|
||||
);
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
existingSets
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete remote previews that are no longer in config
|
||||
if (existingSets) {
|
||||
for (const [previewType, previewSet] of existingSets) {
|
||||
if (previews?.[previewType]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPreviews = previewSet.attributes.appPreviews || [];
|
||||
for (const preview of existingPreviews) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})...`,
|
||||
success: `Deleted video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
|
||||
failure: `Failed deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('maps "slowest" to METRIC_VALUE DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "fastest" to METRIC_VALUE ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "newest" to TIMESTAMP DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "oldest" to TIMESTAMP ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchObserveEventsAsync, () => {
|
||||
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'app-123',
|
||||
filter: {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
},
|
||||
first: 10,
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes platform in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 5,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
platform: AppObservePlatform.Ios,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appVersion in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '1.2.0',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appVersion: '1.2.0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appUpdateId in filter when updateId is provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
updateId: 'update-abc-123',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appUpdateId: 'update-abc-123',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
|
||||
expect(calledFilter).not.toHaveProperty('platform');
|
||||
expect(calledFilter).not.toHaveProperty('appVersion');
|
||||
expect(calledFilter).not.toHaveProperty('appUpdateId');
|
||||
});
|
||||
|
||||
it('returns events and pageInfo from the query result', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].metricValue).toBe(1.23);
|
||||
expect(result.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
after: 'cursor-abc',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({ first: 10, after: 'cursor-abc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits after when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledVars = mockEventsAsync.mock.calls[0][1];
|
||||
expect(calledVars).not.toHaveProperty('after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function makeAppVersion(
|
||||
appVersion: string,
|
||||
metrics: Array<{
|
||||
metricName: string;
|
||||
eventCount: number;
|
||||
statistics: {
|
||||
min: number;
|
||||
max: number;
|
||||
median: number;
|
||||
average: number;
|
||||
p80: number;
|
||||
p90: number;
|
||||
p99: number;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
__typename: 'AppObserveAppVersion' as const,
|
||||
appVersion,
|
||||
eventCount: metrics.reduce((sum, m) => sum + m.eventCount, 0),
|
||||
uniqueUserCount: 50,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: metrics.map(m => ({
|
||||
__typename: 'AppObserveAppVersionMetric' as const,
|
||||
...m,
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
...m.statistics,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchObserveMetricsAsync', () => {
|
||||
const mockAppVersionsAsync = jest.mocked(ObserveQuery.appVersionsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppVersionsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('fetches app versions with metricNames and assembles metricsMap', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: {
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
},
|
||||
},
|
||||
{
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
eventCount: 80,
|
||||
statistics: {
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'project-123',
|
||||
platform: AppObservePlatform.Ios,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
metricNames: ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
});
|
||||
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
|
||||
const metricsForVersion = metricsMap.get(key)!;
|
||||
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
eventCount: 100,
|
||||
});
|
||||
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
eventCount: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('fans out across multiple platforms', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([]);
|
||||
|
||||
await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
const platforms = mockAppVersionsAsync.mock.calls.map(call => call[1].platform);
|
||||
expect(platforms).toContain(AppObservePlatform.Ios);
|
||||
expect(platforms).toContain(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockAppVersionsAsync.mockImplementation(async (_client, { platform }) => {
|
||||
if (platform === AppObservePlatform.Android) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return [
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
});
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
eventCount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty map when all queries fail', async () => {
|
||||
mockAppVersionsAsync.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('3.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 10,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.2,
|
||||
median: 0.15,
|
||||
average: 0.15,
|
||||
p80: 0.18,
|
||||
p90: 0.19,
|
||||
p99: 0.2,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
|
||||
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('accumulates totalEventCounts per metric per platform', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { totalEventCounts } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(totalEventCounts.get('expo.app_startup.tti:IOS')).toBe(150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
|
||||
|
||||
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appIdentifier: 'com.example.app',
|
||||
appName: 'ExampleApp',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
eventBatchId: 'batch-1',
|
||||
tags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
const withNextPage: PageInfo = {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-abc',
|
||||
};
|
||||
|
||||
describe(buildObserveEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.85,
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
deviceOsVersion: '14',
|
||||
deviceModel: 'Pixel 8',
|
||||
countryCode: 'PL',
|
||||
timestamp: '2025-01-14T08:15:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// Escape codes are included, because the header is bolded.
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mValue App Version Platform Device Country Timestamp [22m
|
||||
----- ----------- ---------- --------- ------- ----------------------
|
||||
1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
|
||||
0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns yellow warning for empty array', () => {
|
||||
const output = buildObserveEventsTable([], noNextPage);
|
||||
expect(output).toContain('No events found.');
|
||||
});
|
||||
|
||||
it('shows metric name in summary header when options are provided', () => {
|
||||
const events = [createMockEvent({ metricName: 'expo.app_startup.tti' })];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
daysBack: 30,
|
||||
});
|
||||
|
||||
expect(output).toContain('TTI events for the last 30 days');
|
||||
});
|
||||
|
||||
it('shows date range in summary header when start/end provided', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(output).toContain('Cold Launch events from Jan 1, 2025 to Feb 1, 2025');
|
||||
});
|
||||
|
||||
it('shows - for null countryCode', () => {
|
||||
const events = [createMockEvent({ countryCode: null })];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// The country column should contain a dash
|
||||
const lines = output.split('\n');
|
||||
const dataLine = lines[2]; // header, separator, first data row
|
||||
expect(dataLine).toContain('-');
|
||||
});
|
||||
|
||||
it('appends next page hint when hasNextPage is true', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, withNextPage);
|
||||
|
||||
expect(output).toContain('Next page: --after cursor-abc');
|
||||
});
|
||||
|
||||
it('does not append next page hint when hasNextPage is false', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
expect(output).not.toContain('Next page');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveEventsJson, () => {
|
||||
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, withNextPage);
|
||||
|
||||
expect(result).toEqual({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: 'cursor-abc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null optional fields', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
countryCode: null,
|
||||
sessionId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, noNextPage);
|
||||
|
||||
expect(result.events[0].countryCode).toBeNull();
|
||||
expect(result.events[0].sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty events array for empty input', () => {
|
||||
const result = buildObserveEventsJson([], noNextPage);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import {
|
||||
ObserveMetricsMap,
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
makeMetricsKey,
|
||||
resolveStatKey,
|
||||
type MetricValues,
|
||||
} from '../formatMetrics';
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
|
||||
|
||||
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
|
||||
return {
|
||||
min: 0.1,
|
||||
median: 0.3,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveMetricsTable, () => {
|
||||
it('formats metrics grouped by version with metric columns', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
iosKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
|
||||
metricsMap.set(
|
||||
androidKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
// The header is bolded, thus the escape characters in the snapshot
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- ----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
|
||||
[1mAndroid[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----------
|
||||
1.1.0 0.25s (120) 1.12s (100)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('shows - for versions with no matching metric data', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----
|
||||
2.0.0 - (-) - (-)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns message when no metrics data found', () => {
|
||||
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
expect(output).toMatchInlineSnapshot(`"[33mNo metrics data found.[39m"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveMetricsJson, () => {
|
||||
it('produces JSON with all stats per metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions).toHaveLength(1);
|
||||
expect(result.versions[0]).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
platform: AppPlatform.Ios,
|
||||
buildNumbers: [],
|
||||
updateIds: [],
|
||||
metrics: {
|
||||
'expo.app_startup.tti': {
|
||||
min: 0.1,
|
||||
median: 0.12,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('produces null values when no observe data matches for a metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics).toEqual({
|
||||
'expo.app_startup.tti': {
|
||||
min: null,
|
||||
median: null,
|
||||
max: null,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(makeMetricsKey, () => {
|
||||
it('creates a key from version and platform', () => {
|
||||
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveStatKey, () => {
|
||||
it('resolves canonical stat names', () => {
|
||||
expect(resolveStatKey('min')).toBe('min');
|
||||
expect(resolveStatKey('max')).toBe('max');
|
||||
expect(resolveStatKey('median')).toBe('median');
|
||||
expect(resolveStatKey('average')).toBe('average');
|
||||
expect(resolveStatKey('p80')).toBe('p80');
|
||||
expect(resolveStatKey('p90')).toBe('p90');
|
||||
expect(resolveStatKey('p99')).toBe('p99');
|
||||
expect(resolveStatKey('eventCount')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('resolves short aliases', () => {
|
||||
expect(resolveStatKey('med')).toBe('median');
|
||||
expect(resolveStatKey('avg')).toBe('average');
|
||||
expect(resolveStatKey('count')).toBe('eventCount');
|
||||
expect(resolveStatKey('event_count')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_TABLE', () => {
|
||||
it('defaults to median, eventCount', () => {
|
||||
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_JSON', () => {
|
||||
it('includes all stats', () => {
|
||||
expect(DEFAULT_STATS_JSON).toEqual([
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom stats parameter', () => {
|
||||
it('table renders only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p99', 'eventCount']
|
||||
);
|
||||
|
||||
expect(output).toContain('TTI');
|
||||
expect(output).toContain('0.90s (42)');
|
||||
expect(output).not.toContain('TTI Min');
|
||||
expect(output).not.toContain('TTI Med');
|
||||
expect(output).not.toContain('TTI Max');
|
||||
});
|
||||
|
||||
it("table formats eventCount as integer without 's' suffix", () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: 100,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
|
||||
|
||||
expect(output).toContain('100');
|
||||
expect(output).not.toContain('100s');
|
||||
expect(output).not.toContain('100.00s');
|
||||
});
|
||||
|
||||
it('JSON includes only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p90', 'eventCount']
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
p90: 0.4,
|
||||
eventCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON uses default stats when not specified', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
|
||||
});
|
||||
|
||||
it('resolves short alias "ttr" to full metric name', () => {
|
||||
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
|
||||
});
|
||||
|
||||
it('resolves short alias "cold_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "warm_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "bundle_load" to full metric name', () => {
|
||||
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
|
||||
});
|
||||
|
||||
it('passes through full metric names unchanged', () => {
|
||||
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
|
||||
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
|
||||
'expo.app_startup.cold_launch_time'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unknown alias', () => {
|
||||
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
|
||||
});
|
||||
|
||||
it('passes through dot-containing custom metric names', () => {
|
||||
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export enum EventsOrderPreset {
|
||||
Slowest = 'SLOWEST',
|
||||
Fastest = 'FASTEST',
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST',
|
||||
}
|
||||
|
||||
export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
|
||||
const preset = input.toUpperCase() as EventsOrderPreset;
|
||||
switch (preset) {
|
||||
case EventsOrderPreset.Slowest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Fastest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
case EventsOrderPreset.Newest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Oldest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchObserveEventsOptions {
|
||||
metricName: string;
|
||||
orderBy: AppObserveEventsOrderBy;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
}
|
||||
|
||||
interface FetchObserveEventsResult {
|
||||
events: AppObserveEvent[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export async function fetchObserveEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveEventsOptions
|
||||
): Promise<FetchObserveEventsResult> {
|
||||
const filter: AppObserveEventsFilter = {
|
||||
metricName: options.metricName,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.eventsAsync(graphqlClient, {
|
||||
appId,
|
||||
filter,
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: options.orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export async function fetchTotalEventCountAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricName: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<number> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
try {
|
||||
const versions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: appPlatformToObservePlatform[appPlatform],
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames: [metricName],
|
||||
});
|
||||
return versions.reduce((sum, v) => {
|
||||
const metric = v.metrics.find(m => m.metricName === metricName);
|
||||
return sum + (metric?.eventCount ?? 0);
|
||||
}, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const counts = await Promise.all(queries);
|
||||
return counts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
BuildNumbersMap,
|
||||
MetricValues,
|
||||
ObserveMetricsMap,
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new EasCommandError(
|
||||
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchObserveMetricsResult {
|
||||
metricsMap: ObserveMetricsMap;
|
||||
buildNumbersMap: BuildNumbersMap;
|
||||
updateIdsMap: UpdateIdsMap;
|
||||
totalEventCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
export async function fetchObserveMetricsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricNames: string[],
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<FetchObserveMetricsResult> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
});
|
||||
return { appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const buildNumbersMap: BuildNumbersMap = new Map();
|
||||
const updateIdsMap: UpdateIdsMap = new Map();
|
||||
const totalEventCounts = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
const { appPlatform, appVersions } = result;
|
||||
|
||||
for (const version of appVersions) {
|
||||
const key = makeMetricsKey(version.appVersion, appPlatform);
|
||||
if (!metricsMap.has(key)) {
|
||||
metricsMap.set(key, new Map());
|
||||
}
|
||||
if (!buildNumbersMap.has(key)) {
|
||||
buildNumbersMap.set(
|
||||
key,
|
||||
version.buildNumbers.map(bn => bn.appBuildNumber)
|
||||
);
|
||||
}
|
||||
if (!updateIdsMap.has(key)) {
|
||||
updateIdsMap.set(
|
||||
key,
|
||||
version.updates.map(u => u.appUpdateId)
|
||||
);
|
||||
}
|
||||
|
||||
for (const metric of version.metrics) {
|
||||
const values: MetricValues = {
|
||||
min: metric.statistics.min,
|
||||
max: metric.statistics.max,
|
||||
median: metric.statistics.median,
|
||||
average: metric.statistics.average,
|
||||
p80: metric.statistics.p80,
|
||||
p90: metric.statistics.p90,
|
||||
p99: metric.statistics.p99,
|
||||
eventCount: metric.eventCount,
|
||||
};
|
||||
metricsMap.get(key)!.set(metric.metricName, values);
|
||||
|
||||
const eventCountKey = `${metric.metricName}:${appPlatform}`;
|
||||
totalEventCounts.set(
|
||||
eventCountKey,
|
||||
(totalEventCounts.get(eventCountKey) ?? 0) + metric.eventCount
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export interface AppVersionsResult {
|
||||
platform: AppPlatform;
|
||||
appVersions: AppObserveAppVersion[];
|
||||
}
|
||||
|
||||
export async function fetchObserveVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<AppVersionsResult[]> {
|
||||
const queries = platforms.map(async (appPlatform): Promise<AppVersionsResult | null> => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
return { platform: appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch app versions for ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
return results.filter((r): r is AppVersionsResult => r !== null);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveEvent, PageInfo } from '../graphql/generated';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export interface ObserveEventJson {
|
||||
id: string;
|
||||
metricName: string;
|
||||
metricValue: number;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
sessionId: string | null;
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildEventsTableOptions {
|
||||
metricName: string;
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
totalEventCount?: number;
|
||||
}
|
||||
|
||||
export function buildObserveEventsTable(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo,
|
||||
options?: BuildEventsTableOptions
|
||||
): string {
|
||||
if (events.length === 0) {
|
||||
return chalk.yellow('No events found.');
|
||||
}
|
||||
|
||||
const hasUpdates = events.some(e => e.appUpdateId);
|
||||
|
||||
const headers = [
|
||||
'Value',
|
||||
'App Version',
|
||||
...(hasUpdates ? ['Update'] : []),
|
||||
'Platform',
|
||||
'Device',
|
||||
'Country',
|
||||
'Timestamp',
|
||||
];
|
||||
|
||||
const rows: string[][] = events.map(event => [
|
||||
`${event.metricValue.toFixed(2)}s`,
|
||||
`${event.appVersion} (${event.appBuildNumber})`,
|
||||
...(hasUpdates ? [event.appUpdateId ?? '-'] : []),
|
||||
`${event.deviceOs} ${event.deviceOsVersion}`,
|
||||
event.deviceModel,
|
||||
event.countryCode ?? '-',
|
||||
formatTimestamp(event.timestamp),
|
||||
]);
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options) {
|
||||
const metricDisplay = getMetricDisplayName(options.metricName);
|
||||
let timeDesc: string;
|
||||
if (options.daysBack) {
|
||||
timeDesc = `for the last ${options.daysBack} days`;
|
||||
} else if (options.startTime && options.endTime) {
|
||||
timeDesc = `from ${formatDate(options.startTime)} to ${formatDate(options.endTime)}`;
|
||||
} else {
|
||||
timeDesc = '';
|
||||
}
|
||||
const totalDesc =
|
||||
options.totalEventCount != null
|
||||
? ` — ${options.totalEventCount.toLocaleString()} total events`
|
||||
: '';
|
||||
lines.push(chalk.bold(`${metricDisplay} events ${timeDesc}${totalDesc}`.trim()), '');
|
||||
}
|
||||
|
||||
lines.push(chalk.bold(headerLine), separatorLine, ...dataLines);
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveEventsJson(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo
|
||||
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
metricName: event.metricName,
|
||||
metricValue: event.metricValue,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type StatisticKey =
|
||||
| 'min'
|
||||
| 'max'
|
||||
| 'median'
|
||||
| 'average'
|
||||
| 'p80'
|
||||
| 'p90'
|
||||
| 'p99'
|
||||
| 'eventCount';
|
||||
|
||||
export const STAT_ALIASES: Record<string, StatisticKey> = {
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
med: 'median',
|
||||
median: 'median',
|
||||
avg: 'average',
|
||||
average: 'average',
|
||||
p80: 'p80',
|
||||
p90: 'p90',
|
||||
p99: 'p99',
|
||||
count: 'eventCount',
|
||||
event_count: 'eventCount',
|
||||
eventCount: 'eventCount',
|
||||
};
|
||||
|
||||
export const STAT_DISPLAY_NAMES: Record<StatisticKey, string> = {
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
median: 'Med',
|
||||
average: 'Avg',
|
||||
p80: 'P80',
|
||||
p90: 'P90',
|
||||
p99: 'P99',
|
||||
eventCount: 'Count',
|
||||
};
|
||||
|
||||
export function resolveStatKey(input: string): StatisticKey {
|
||||
const resolved = STAT_ALIASES[input];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatValue(stat: StatisticKey, value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (stat === 'eventCount') {
|
||||
return String(value);
|
||||
}
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatMergedCell(
|
||||
stat: StatisticKey,
|
||||
statValue: number | null | undefined,
|
||||
eventCount: number | null | undefined
|
||||
): string {
|
||||
const formatted = formatStatValue(stat, statValue);
|
||||
const count = eventCount != null ? String(eventCount) : '-';
|
||||
return `${formatted} (${count})`;
|
||||
}
|
||||
|
||||
export interface MetricValues {
|
||||
min: number | null | undefined;
|
||||
max: number | null | undefined;
|
||||
median: number | null | undefined;
|
||||
average: number | null | undefined;
|
||||
p80: number | null | undefined;
|
||||
p90: number | null | undefined;
|
||||
p99: number | null | undefined;
|
||||
eventCount: number | null | undefined;
|
||||
}
|
||||
|
||||
type ObserveMetricsKey = `${string}:${AppPlatform}`;
|
||||
|
||||
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
|
||||
export type BuildNumbersMap = Map<ObserveMetricsKey, string[]>;
|
||||
export type UpdateIdsMap = Map<ObserveMetricsKey, string[]>;
|
||||
|
||||
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
|
||||
return `${appVersion}:${platform}`;
|
||||
}
|
||||
|
||||
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
|
||||
const lastColon = key.lastIndexOf(':');
|
||||
return {
|
||||
appVersion: key.slice(0, lastColon),
|
||||
platform: key.slice(lastColon + 1) as AppPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
|
||||
|
||||
export interface ObserveMetricsVersionResult {
|
||||
appVersion: string;
|
||||
platform: AppPlatform;
|
||||
buildNumbers: string[];
|
||||
updateIds: string[];
|
||||
metrics: Record<string, MetricValuesJson>;
|
||||
}
|
||||
|
||||
export interface ObserveMetricsJsonOutput {
|
||||
versions: ObserveMetricsVersionResult[];
|
||||
totalEventCounts: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export function buildObserveMetricsJson(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[],
|
||||
totalEventCounts?: Map<string, number>,
|
||||
buildNumbersMap?: BuildNumbersMap,
|
||||
updateIdsMap?: UpdateIdsMap
|
||||
): ObserveMetricsJsonOutput {
|
||||
const versions: ObserveMetricsVersionResult[] = [];
|
||||
|
||||
for (const [key, versionMetrics] of metricsMap) {
|
||||
const { appVersion, platform } = parseMetricsKey(key);
|
||||
|
||||
const metrics: Record<string, MetricValuesJson> = {};
|
||||
for (const metricName of metricNames) {
|
||||
const values = versionMetrics.get(metricName);
|
||||
const statValues: MetricValuesJson = {};
|
||||
for (const stat of stats) {
|
||||
statValues[stat] = values?.[stat] ?? null;
|
||||
}
|
||||
metrics[metricName] = statValues;
|
||||
}
|
||||
|
||||
versions.push({
|
||||
appVersion,
|
||||
platform,
|
||||
buildNumbers: buildNumbersMap?.get(key) ?? [],
|
||||
updateIds: updateIdsMap?.get(key) ?? [],
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
// Group total event counts by metric → platform
|
||||
const counts: Record<string, Record<string, number>> = {};
|
||||
if (totalEventCounts) {
|
||||
for (const [key, count] of totalEventCounts) {
|
||||
const lastColon = key.lastIndexOf(':');
|
||||
const metricName = key.slice(0, lastColon);
|
||||
const platform = key.slice(lastColon + 1);
|
||||
if (!counts[metricName]) {
|
||||
counts[metricName] = {};
|
||||
}
|
||||
counts[metricName][platform] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return { versions, totalEventCounts: counts };
|
||||
}
|
||||
|
||||
function buildStatsDescription(displayStats: StatisticKey[]): string {
|
||||
return displayStats.map(s => STAT_DISPLAY_NAMES[s]).join(', ');
|
||||
}
|
||||
|
||||
function buildTimeRangeDescription(daysBack?: number): string {
|
||||
if (daysBack) {
|
||||
return `for the last ${daysBack} days`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][], footerRow?: string[]): string {
|
||||
const allRows = footerRow ? [...rows, footerRow] : rows;
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
|
||||
);
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
|
||||
if (footerRow) {
|
||||
lines.push(separatorLine);
|
||||
lines.push(footerRow.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveMetricsTable(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[],
|
||||
options?: {
|
||||
daysBack?: number;
|
||||
buildNumbersMap?: BuildNumbersMap;
|
||||
totalEventCounts?: Map<string, number>;
|
||||
}
|
||||
): string {
|
||||
const { versions: results } = buildObserveMetricsJson(metricsMap, metricNames, stats);
|
||||
|
||||
if (results.length === 0) {
|
||||
return chalk.yellow('No metrics data found.');
|
||||
}
|
||||
|
||||
const displayStats = stats.filter(s => s !== 'eventCount');
|
||||
const hasEventCount = stats.includes('eventCount');
|
||||
|
||||
// Build summary header
|
||||
const statsDesc = displayStats.length > 0 ? buildStatsDescription(displayStats) : 'Event count';
|
||||
const timeDesc = buildTimeRangeDescription(options?.daysBack);
|
||||
const countSuffix = hasEventCount && displayStats.length > 0 ? ' (event count)' : '';
|
||||
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
|
||||
|
||||
// Group results by platform
|
||||
const byPlatform = new Map<AppPlatform, ObserveMetricsVersionResult[]>();
|
||||
for (const result of results) {
|
||||
if (!byPlatform.has(result.platform)) {
|
||||
byPlatform.set(result.platform, []);
|
||||
}
|
||||
byPlatform.get(result.platform)!.push(result);
|
||||
}
|
||||
|
||||
// Build metric column headers
|
||||
const metricHeaders: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const name = getMetricDisplayName(m);
|
||||
if (displayStats.length > 0 && hasEventCount) {
|
||||
// Merged mode: one column per metric
|
||||
metricHeaders.push(name);
|
||||
} else {
|
||||
// Separate columns per stat
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['eventCount'] as StatisticKey[])) {
|
||||
metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers = ['App Version', ...metricHeaders];
|
||||
|
||||
const sections: string[] = [chalk.bold(summaryLine)];
|
||||
|
||||
for (const [platform, platformResults] of byPlatform) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (const result of platformResults) {
|
||||
const key = makeMetricsKey(result.appVersion, result.platform);
|
||||
const buildNumbers = options?.buildNumbersMap?.get(key);
|
||||
const versionLabel = buildNumbers?.length
|
||||
? `${result.appVersion} (${buildNumbers.join(', ')})`
|
||||
: result.appVersion;
|
||||
|
||||
const metricCells: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const values = result.metrics[m];
|
||||
if (displayStats.length > 0 && hasEventCount) {
|
||||
for (const stat of displayStats) {
|
||||
metricCells.push(
|
||||
formatMergedCell(stat, values?.[stat] ?? null, values?.eventCount ?? null)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['eventCount'] as StatisticKey[])) {
|
||||
metricCells.push(formatStatValue(stat, values?.[stat] ?? null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.push([versionLabel, ...metricCells]);
|
||||
}
|
||||
|
||||
let footerRow: string[] | undefined;
|
||||
if (options?.totalEventCounts) {
|
||||
const countCells: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const count = options.totalEventCounts.get(`${m}:${platform}`);
|
||||
countCells.push(count != null ? count.toLocaleString() : '-');
|
||||
}
|
||||
if (countCells.some(c => c !== '-')) {
|
||||
footerRow = ['Total events', ...countCells];
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(renderTable(headers, rows, footerRow));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AppObserveAppBuildNumber,
|
||||
AppObserveAppEasBuild,
|
||||
AppObserveAppUpdate,
|
||||
} from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { AppVersionsResult } from './fetchVersions';
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface AppVersionJson {
|
||||
platform: string;
|
||||
appVersion: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
buildNumbers: AppBuildNumberJson[];
|
||||
updates: AppUpdateJson[];
|
||||
}
|
||||
|
||||
export interface AppBuildNumberJson {
|
||||
appBuildNumber: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppUpdateJson {
|
||||
appUpdateId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppEasBuildJson {
|
||||
easBuildId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
}
|
||||
|
||||
function mapEasBuilds(easBuilds: AppObserveAppEasBuild[]): AppEasBuildJson[] {
|
||||
return easBuilds.map(b => ({
|
||||
easBuildId: b.easBuildId,
|
||||
firstSeenAt: b.firstSeenAt,
|
||||
eventCount: b.eventCount,
|
||||
uniqueUserCount: b.uniqueUserCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapBuildNumbers(buildNumbers: AppObserveAppBuildNumber[]): AppBuildNumberJson[] {
|
||||
return buildNumbers.map(bn => ({
|
||||
appBuildNumber: bn.appBuildNumber,
|
||||
firstSeenAt: bn.firstSeenAt,
|
||||
eventCount: bn.eventCount,
|
||||
uniqueUserCount: bn.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(bn.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
function mapUpdates(updates: AppObserveAppUpdate[]): AppUpdateJson[] {
|
||||
return updates.map(u => ({
|
||||
appUpdateId: u.appUpdateId,
|
||||
firstSeenAt: u.firstSeenAt,
|
||||
eventCount: u.eventCount,
|
||||
uniqueUserCount: u.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(u.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildObserveVersionsJson(results: AppVersionsResult[]): AppVersionJson[] {
|
||||
const output: AppVersionJson[] = [];
|
||||
for (const { platform, appVersions } of results) {
|
||||
for (const v of appVersions) {
|
||||
output.push({
|
||||
platform: platform as string,
|
||||
appVersion: v.appVersion,
|
||||
firstSeenAt: v.firstSeenAt,
|
||||
eventCount: v.eventCount,
|
||||
uniqueUserCount: v.uniqueUserCount,
|
||||
buildNumbers: mapBuildNumbers(v.buildNumbers),
|
||||
updates: mapUpdates(v.updates),
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveVersionsTable(results: AppVersionsResult[]): string {
|
||||
const hasAnyVersions = results.some(r => r.appVersions.length > 0);
|
||||
|
||||
if (!hasAnyVersions) {
|
||||
return chalk.yellow('No app versions found.');
|
||||
}
|
||||
|
||||
const headers = ['App Version', 'First Seen', 'Events', 'Users', 'Builds', 'Updates'];
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const { platform, appVersions } of results) {
|
||||
if (appVersions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
sections.push('');
|
||||
}
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = appVersions.map(version => [
|
||||
version.appVersion,
|
||||
formatDate(version.firstSeenAt),
|
||||
String(version.eventCount),
|
||||
String(version.uniqueUserCount),
|
||||
String(version.buildNumbers.length),
|
||||
String(version.updates.length),
|
||||
]);
|
||||
|
||||
sections.push(renderTable(headers, rows));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
|
||||
export const METRIC_ALIASES: Record<string, string> = {
|
||||
tti: 'expo.app_startup.tti',
|
||||
ttr: 'expo.app_startup.ttr',
|
||||
cold_launch: 'expo.app_startup.cold_launch_time',
|
||||
warm_launch: 'expo.app_startup.warm_launch_time',
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
if (METRIC_ALIASES[input]) {
|
||||
return METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { validateDateFlag } from './fetchMetrics';
|
||||
|
||||
export const DEFAULT_DAYS_BACK = 60;
|
||||
|
||||
export function startAndEndTime({
|
||||
daysBack,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
daysBack?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}) {
|
||||
let startTime: string;
|
||||
let endTime: string;
|
||||
|
||||
if (daysBack) {
|
||||
endTime = new Date().toISOString();
|
||||
startTime = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
endTime = end ?? new Date().toISOString();
|
||||
startTime =
|
||||
start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
export function resolveTimeRange(flags: { days?: number; start?: string; end?: string }): {
|
||||
daysBack?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} {
|
||||
if (flags.start) {
|
||||
validateDateFlag(flags.start, '--start');
|
||||
}
|
||||
if (flags.end) {
|
||||
validateDateFlag(flags.end, '--end');
|
||||
}
|
||||
|
||||
const daysBack = flags.days ?? (flags.start ? undefined : DEFAULT_DAYS_BACK);
|
||||
const { startTime, endTime } = startAndEndTime({
|
||||
daysBack,
|
||||
start: flags.start,
|
||||
end: flags.end,
|
||||
});
|
||||
|
||||
return { daysBack, startTime, endTime };
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export async function* batchUploadAsync(
|
||||
(() => {
|
||||
onProgressUpdate(getProgressValue());
|
||||
});
|
||||
let firstError: unknown = null;
|
||||
try {
|
||||
let index = 0;
|
||||
while (index < payloads.length || queue.size > 0) {
|
||||
@@ -215,29 +216,38 @@ export async function* batchUploadAsync(
|
||||
progressTracker[currentIndex] = progress;
|
||||
sendProgressUpdate();
|
||||
});
|
||||
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).finally(
|
||||
() => {
|
||||
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).then(
|
||||
result => {
|
||||
queue.delete(uploadPromise);
|
||||
progressTracker[currentIndex] = 1;
|
||||
return result;
|
||||
},
|
||||
error => {
|
||||
queue.delete(uploadPromise);
|
||||
firstError ??= error;
|
||||
controller.abort();
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
queue.add(uploadPromise);
|
||||
yield { payload, progress: getProgressValue() };
|
||||
}
|
||||
if (firstError) {
|
||||
break;
|
||||
}
|
||||
yield {
|
||||
...(await Promise.race(queue)),
|
||||
progress: getProgressValue(),
|
||||
};
|
||||
}
|
||||
|
||||
if (queue.size > 0) {
|
||||
controller.abort();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
throw error;
|
||||
firstError ??= error;
|
||||
}
|
||||
}
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadProgressBar {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.5.0",
|
||||
"version": "18.7.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -188,7 +188,12 @@ const BuildFunctionCallSchema = Joi.object({
|
||||
name: Joi.string(),
|
||||
workingDirectory: Joi.string(),
|
||||
shell: Joi.string(),
|
||||
env: Joi.object().pattern(Joi.string(), Joi.string().allow('')),
|
||||
env: Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.alternatives()
|
||||
.try(Joi.number().strict(), Joi.string().allow(''))
|
||||
.custom(value => String(value))
|
||||
),
|
||||
if: Joi.string(),
|
||||
timeout_minutes: Joi.number().positive(),
|
||||
// Internal field for metrics collection. Not documented publicly.
|
||||
|
||||
@@ -258,7 +258,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('invalid env type', () => {
|
||||
const buildConfig = {
|
||||
@@ -278,7 +278,48 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('env number coerced to string', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
run: {
|
||||
command: 'echo 123',
|
||||
env: {
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
assert(isBuildStepCommandRun(config.build.steps[0]));
|
||||
expect(config.build.steps[0].run.env).toEqual({ HOMEBREW_NO_AUTO_UPDATE: '1' });
|
||||
});
|
||||
test('numeric-like string env value is not coerced', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
run: {
|
||||
command: 'echo 123',
|
||||
env: {
|
||||
PORT: '001',
|
||||
SCALE: '1e3',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
assert(isBuildStepCommandRun(config.build.steps[0]));
|
||||
expect(config.build.steps[0].run.env).toEqual({ PORT: '001', SCALE: '1e3' });
|
||||
});
|
||||
test('valid timeout_minutes', () => {
|
||||
const buildConfig = {
|
||||
@@ -471,6 +512,36 @@ describe(validateConfig, () => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).not.toThrowError();
|
||||
});
|
||||
test('call with env number coerced to string', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
say_hi: {
|
||||
env: {
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1,
|
||||
PORT: 3000,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
functions: {
|
||||
say_hi: {
|
||||
command: 'echo "Hi!"',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
const step = config.build.steps[0] as BuildStepFunctionCall;
|
||||
expect(step['say_hi'].env).toEqual({
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
PORT: '3000',
|
||||
VERBOSE: 'true',
|
||||
});
|
||||
});
|
||||
test('call with if statement', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
@@ -518,7 +589,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('invalid env structure', () => {
|
||||
const buildConfig = {
|
||||
@@ -544,7 +615,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('call with inputs boolean', () => {
|
||||
const buildConfig = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.5.0",
|
||||
"version": "18.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -2856,14 +2856,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.5.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.7.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2871,7 +2871,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -3111,7 +3111,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:18.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:18.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -3128,12 +3128,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:18.5.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:18.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -3481,11 +3481,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:18.5.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:18.6.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -3554,16 +3554,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.5.0"
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -10331,7 +10331,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10944,8 +10944,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
@@ -11002,8 +11002,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-json": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-json": "npm:18.6.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
@@ -11018,7 +11018,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:5.0.0"
|
||||
"@graphql-codegen/introspection": "npm:4.0.0"
|
||||
|
||||
Reference in new issue
Block a user