Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 06dbc19a5a docs(metadata/pricing): add TODOs for schedule push via expo/third-party#147
Document the new API surface from expo/third-party#147 that will enable
schedule push once @expo/apple-utils is bumped:
- AppPriceSchedule.createAsync (replaces entire schedule, no merge)
- AppPricePoint.getForAppAsync (resolve tiers to price point IDs)
- App.getPriceScheduleAsync (read current schedule)

Update the schedule-not-pushed warning to reference the tracking PR.
Add comments clarifying territory availability uses the legacy path
which is separate from pricing in the modern ASC API.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 13:36:35 -07:00
evanbaconandClaude Opus 4.6 84fadc4eac feat(metadata): support pricing tier and territory availability
EAS metadata previously had no way to set the App Store price tier or
territory availability, so every new app sat at free / worldwide
forever. Add `pricing` and `availability` blocks to the metadata schema
plus a new `PricingTask` that pulls and pushes both via the legacy
`App.updateAsync({ appPriceTier, territories })` endpoint exposed by
@expo/apple-utils.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:39:35 -07:00
64 changed files with 1474 additions and 7971 deletions

No files matched your search

+2 -16
View File
@@ -8,31 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🐛 Bug fixes
### 🧹 Chores
## [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))
- [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))
### 🧹 Chores
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
@@ -40,7 +27,6 @@ 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
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.7.0",
"version": "18.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.7.0",
"version": "18.5.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.6.0",
"@expo/eas-build-job": "18.5.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.6.0",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
+5 -2
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,7 +82,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
@@ -18,9 +18,7 @@ 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';
@@ -49,8 +47,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
@@ -1,177 +0,0 @@
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,12 +1,5 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -37,7 +30,6 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -46,30 +38,17 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
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;
createBuildStepsFromFunctionGroupCall: globalCtx => {
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) {
@@ -77,13 +56,11 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -96,19 +73,16 @@ 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: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -117,7 +91,6 @@ 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 }',
@@ -125,26 +98,18 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
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 }',
@@ -152,35 +117,29 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
];
}
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 }',
},
@@ -188,18 +147,16 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -209,7 +166,6 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -217,7 +173,6 @@ 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 }',
@@ -226,7 +181,6 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -234,15 +188,10 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -250,16 +199,11 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
...(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 }',
@@ -271,29 +215,24 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
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:
@@ -303,7 +242,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -311,7 +249,6 @@ 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 }',
@@ -320,7 +257,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -328,26 +264,18 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
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 }',
@@ -358,29 +286,24 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
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:
@@ -390,7 +313,6 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -398,7 +320,6 @@ 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 }',
@@ -407,7 +328,6 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -415,33 +335,21 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
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 }',
@@ -451,10 +359,8 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
@@ -1,77 +0,0 @@
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,
});
});
});
@@ -1,50 +0,0 @@
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,6 +34,7 @@ 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();
@@ -211,64 +212,4 @@ 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),
}
);
});
});
@@ -1,43 +0,0 @@
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.
}
},
});
}
@@ -1,26 +0,0 @@
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 });
}
},
});
}
@@ -1,11 +1,5 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -50,14 +44,6 @@ 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- `)}`);
@@ -267,32 +253,3 @@ 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,12 +9,7 @@ import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import {
isUsingModernYarnVersion,
readAndLogPackageJson,
readEasJsonContents,
runExpoCliCommand,
} from '../project';
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
@@ -153,23 +148,3 @@ 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));
});
});
-10
View File
@@ -78,16 +78,6 @@ 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)) {
-1
View File
@@ -11,7 +11,6 @@
"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.6.0",
"version": "18.5.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.6.0"
"@expo/steps": "18.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.6.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -84,232 +84,6 @@ 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: {},
+13 -62
View File
@@ -226,66 +226,6 @@ 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']),
})
.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(),
@@ -319,8 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
app_store_connect: AppStoreConnectContextZ.optional(),
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(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+89 -90
View File
@@ -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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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.7.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.5.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] [--skip-bundler]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
@@ -496,13 +496,12 @@ 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.7.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.5.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -523,7 +522,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.7.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.5.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -561,7 +560,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.7.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.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -601,7 +600,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.7.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.5.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -631,7 +630,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.7.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.5.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -657,7 +656,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.7.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.5.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -709,7 +708,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.7.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.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -727,7 +726,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.7.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.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -745,7 +744,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.7.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.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -762,7 +761,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.7.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.5.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -783,7 +782,7 @@ DESCRIPTION
create a channel
```
_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)_
_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)_
## `eas channel:delete [NAME]`
@@ -804,7 +803,7 @@ DESCRIPTION
Delete a channel
```
_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)_
_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)_
## `eas channel:edit [NAME]`
@@ -826,7 +825,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.7.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.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -846,7 +845,7 @@ DESCRIPTION
list all channels
```
_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)_
_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)_
## `eas channel:pause [NAME]`
@@ -868,7 +867,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.7.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.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -890,7 +889,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.7.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.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -925,7 +924,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.7.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.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -948,7 +947,7 @@ DESCRIPTION
view a channel
```
_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)_
_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)_
## `eas config`
@@ -969,7 +968,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.7.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.5.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -986,7 +985,7 @@ DESCRIPTION
manage credentials
```
_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)_
_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)_
## `eas credentials:configure-build`
@@ -1004,7 +1003,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.7.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.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1033,7 +1032,7 @@ ALIASES
$ eas worker:deploy
```
_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)_
_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)_
## `eas deploy:alias`
@@ -1058,7 +1057,7 @@ ALIASES
$ eas deploy:promote
```
_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)_
_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)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1079,7 +1078,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.7.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.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1100,7 +1099,7 @@ ALIASES
$ eas worker:delete
```
_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)_
_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)_
## `eas deploy:promote`
@@ -1137,7 +1136,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.7.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.5.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1157,7 +1156,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.7.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.5.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1178,7 +1177,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.7.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.5.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1199,7 +1198,7 @@ DESCRIPTION
rename a registered device
```
_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)_
_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)_
## `eas device:view [UDID]`
@@ -1213,7 +1212,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.7.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.5.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1227,7 +1226,7 @@ DESCRIPTION
display environment info
```
_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)_
_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)_
## `eas env:create [ENVIRONMENT]`
@@ -1259,7 +1258,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.7.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.5.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1285,7 +1284,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.7.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.5.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1307,7 +1306,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.7.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.5.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1335,7 +1334,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.7.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.5.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1363,7 +1362,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.7.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.5.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1386,7 +1385,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.7.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.5.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1408,7 +1407,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.7.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.5.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1442,7 +1441,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.7.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.5.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1486,7 +1485,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.7.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.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1517,7 +1516,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.7.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.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1626,7 +1625,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.7.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.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1644,7 +1643,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.7.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.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1662,7 +1661,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.7.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.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1714,7 +1713,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.7.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.5.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1728,7 +1727,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.7.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.5.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1751,7 +1750,7 @@ ALIASES
$ eas init
```
_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)_
_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)_
## `eas project:new [PATH]`
@@ -1775,7 +1774,7 @@ ALIASES
$ eas new
```
_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)_
_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)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1793,7 +1792,7 @@ ALIASES
$ eas onboarding
```
_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)_
_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)_
## `eas submit`
@@ -1826,7 +1825,7 @@ ALIASES
$ eas build:submit
```
_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)_
_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)_
## `eas update`
@@ -1869,7 +1868,7 @@ DESCRIPTION
publish an update group
```
_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)_
_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)_
## `eas update:configure`
@@ -1890,7 +1889,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.7.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.5.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1911,7 +1910,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.7.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.5.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1936,7 +1935,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.7.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.5.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1963,7 +1962,7 @@ DESCRIPTION
view the recent updates
```
_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)_
_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)_
## `eas update:republish`
@@ -2001,7 +2000,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.7.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.5.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2029,7 +2028,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.7.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.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2059,7 +2058,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.7.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.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2080,7 +2079,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.7.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.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2100,7 +2099,7 @@ DESCRIPTION
update group details
```
_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)_
_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)_
## `eas upload`
@@ -2121,7 +2120,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.7.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.5.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2143,7 +2142,7 @@ DESCRIPTION
create a webhook
```
_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)_
_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)_
## `eas webhook:delete [ID]`
@@ -2163,7 +2162,7 @@ DESCRIPTION
delete a webhook
```
_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)_
_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)_
## `eas webhook:list`
@@ -2182,7 +2181,7 @@ DESCRIPTION
list webhooks
```
_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)_
_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)_
## `eas webhook:update`
@@ -2205,7 +2204,7 @@ DESCRIPTION
update a webhook
```
_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)_
_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)_
## `eas webhook:view ID`
@@ -2222,7 +2221,7 @@ DESCRIPTION
view a webhook
```
_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)_
_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)_
## `eas whoami`
@@ -2343,7 +2342,7 @@ DESCRIPTION
to cancel.
```
_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)_
_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)_
## `eas workflow:create [NAME]`
@@ -2363,7 +2362,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.7.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.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2387,7 +2386,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.7.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.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2427,7 +2426,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.7.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.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2449,7 +2448,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.7.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.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2477,7 +2476,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.7.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.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2497,7 +2496,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.7.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.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2519,5 +2518,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.7.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.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+545 -2868
View File
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.7.0",
"version": "18.5.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.6.0",
"@expo/eas-json": "18.6.0",
"@expo/eas-build-job": "18.5.0",
"@expo/eas-json": "18.5.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.6.0",
"@expo/steps": "18.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -204,9 +204,6 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
+67
View File
@@ -800,6 +800,65 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePricing": {
"type": "object",
"additionalProperties": false,
"description": "App pricing configuration. NOTE: Apple migrated to base-territory pricing in 2023. EAS metadata currently uses the legacy `appPriceTier` API exposed by @expo/apple-utils.",
"properties": {
"tier": {
"type": "string",
"description": "App Store price tier id (e.g. \"0\" for free, \"1\" for tier 1).",
"defaultSnippets": [
{ "label": "Free", "body": "0" },
{ "label": "Tier 1", "body": "1" }
]
},
"schedule": {
"type": "array",
"description": "Future scheduled price changes. Each entry switches the app to the given tier on `startDate`. Currently parsed but not pushed (the underlying appPriceSchedules endpoint is not yet wrapped by @expo/apple-utils).",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["startDate", "tier"],
"properties": {
"startDate": {
"type": "string",
"description": "ISO-8601 date the new price tier takes effect.",
"format": "date-time"
},
"tier": {
"type": "string",
"description": "App Store price tier id (e.g. \"0\" for free)."
}
}
}
}
}
},
"AppleAvailability": {
"type": "object",
"additionalProperties": false,
"description": "Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g. \"USA\", \"GBR\", \"JPN\") which match App Store Connect Territory ids.",
"properties": {
"territories": {
"description": "Territories the app is available in. Use the literal string \"all\" to make the app available worldwide.",
"oneOf": [
{
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"description": "ISO-3166 alpha-3 territory code (e.g. \"USA\")."
}
},
{
"type": "string",
"enum": ["all"]
}
]
}
}
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1034,6 +1093,14 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"pricing": {
"$ref": "#/definitions/apple/ApplePricing",
"description": "App pricing configuration (price tier + scheduled changes)."
},
"availability": {
"$ref": "#/definitions/apple/AppleAvailability",
"description": "Territory availability configuration."
}
}
}
@@ -35,16 +35,12 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
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),
});
+2 -10
View File
@@ -47,10 +47,6 @@ 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 = {
@@ -133,9 +129,7 @@ export default class BuildDev extends EasCommand {
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build);
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
await this.startDevServerAsync({ projectDir, platform });
return;
} else {
Log.warn('Artifacts for this build expired.');
@@ -188,9 +182,7 @@ export default class BuildDev extends EasCommand {
downloadSimBuildAutoConfirm: true,
envOverride: env,
});
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
await this.startDevServerAsync({ projectDir, platform });
}
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
+2 -16
View File
@@ -362,22 +362,8 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
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;
}
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -1,280 +0,0 @@
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,
});
});
});
@@ -1,222 +0,0 @@
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(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();
});
});
@@ -1,204 +0,0 @@
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();
});
});
@@ -1,167 +0,0 @@
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,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
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,
})
);
}
}
}
@@ -1,140 +0,0 @@
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,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
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)
);
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(
buildObserveMetricsTable(metricsMap, metricNames, stats, {
daysBack,
buildNumbersMap,
updateIdsMap,
totalEventCounts,
})
);
}
}
}
@@ -1,83 +0,0 @@
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,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveVersions);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
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));
}
}
}
+70 -393
View File
@@ -120,8 +120,6 @@ 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) */
@@ -157,8 +155,6 @@ 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
@@ -771,15 +767,6 @@ 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 */
@@ -1129,6 +1116,7 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = {
export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = {
accountId: Scalars['ID']['input'];
androidAppCredentialsId: Scalars['String']['input'];
credential: Scalars['String']['input'];
};
@@ -1370,7 +1358,6 @@ 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.
@@ -1503,7 +1490,6 @@ 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 */
@@ -1722,16 +1708,6 @@ 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'];
@@ -2074,19 +2050,14 @@ 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>;
@@ -2104,54 +2075,15 @@ 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'];
@@ -2161,7 +2093,6 @@ 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']>;
@@ -2198,8 +2129,6 @@ 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']>;
@@ -2231,22 +2160,35 @@ 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 = {
@@ -2263,8 +2205,6 @@ 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']>;
@@ -2287,6 +2227,22 @@ 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']>;
@@ -2420,16 +2376,10 @@ 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'];
@@ -2480,43 +2430,6 @@ 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',
@@ -3365,36 +3278,6 @@ 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';
@@ -4038,6 +3921,13 @@ 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'];
@@ -4061,31 +3951,6 @@ 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'
@@ -4121,41 +3986,12 @@ 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>;
@@ -4166,12 +4002,10 @@ 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']>;
};
@@ -4222,7 +4056,6 @@ 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']>;
@@ -4230,7 +4063,6 @@ 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']>;
};
@@ -4480,11 +4312,6 @@ 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'];
@@ -4567,11 +4394,6 @@ 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'];
@@ -4799,7 +4621,6 @@ export enum EasBuildWaiverType {
}
export enum EasService {
Agent = 'AGENT',
Builds = 'BUILDS',
Jobs = 'JOBS',
Mcp = 'MCP',
@@ -4810,7 +4631,6 @@ export enum EasServiceMetric {
AssetsRequests = 'ASSETS_REQUESTS',
BandwidthUsage = 'BANDWIDTH_USAGE',
Builds = 'BUILDS',
CreditUsage = 'CREDIT_USAGE',
LocalBuilds = 'LOCAL_BUILDS',
ManifestRequests = 'MANIFEST_REQUESTS',
McpRequests = 'MCP_REQUESTS',
@@ -4935,19 +4755,24 @@ 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 (legacy alias for echoTurn.id, prefer echoTurn) */
/** Turn ID for grouping user message + assistant response (UUID) */
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>;
@@ -4963,10 +4788,7 @@ export type EchoMessageEdge = {
export type EchoMessageMutation = {
__typename?: 'EchoMessageMutation';
/**
* Mark a message as completed (sets completedAt).
* metadata is accepted for backward compatibility but ignored — use echoTurn.completeTurn instead.
*/
/** Mark a message as completed (sets completedAt and optionally updates metadata with tokens) */
completeMessage: EchoMessage;
/** Create a new message */
createMessage: EchoMessage;
@@ -4975,7 +4797,7 @@ export type EchoMessageMutation = {
export type EchoMessageMutationCompleteMessageArgs = {
id: Scalars['ID']['input'];
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
metadata?: InputMaybe<CompleteEchoMessageMetadataInput>;
};
@@ -5066,6 +4888,16 @@ 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;
@@ -5287,57 +5119,6 @@ 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']>;
@@ -5347,8 +5128,6 @@ 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']>;
@@ -5358,7 +5137,7 @@ export type EchoVersion = {
revertedFromEchoVersion?: Maybe<EchoVersion>;
source: EchoVersionSource;
thumbnail?: Maybe<EchoVersionThumbnail>;
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
/** Turn ID that created this version (UUID, optional) */
turnId?: Maybe<Scalars['ID']['output']>;
};
@@ -5444,7 +5223,7 @@ export enum EntityTypeName {
AndroidKeystoreEntity = 'AndroidKeystoreEntity',
AppEntity = 'AppEntity',
AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity',
AppStoreConnectAppEntity = 'AppStoreConnectAppEntity',
AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity',
AppleDeviceEntity = 'AppleDeviceEntity',
AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity',
AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity',
@@ -6101,7 +5880,6 @@ 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'];
};
@@ -6125,8 +5903,8 @@ export type GitHubRepositoryMutation = {
createAndConfigureRepository: BackgroundJobReceipt;
/** Create a GitHub repository for an App */
createGitHubRepository: GitHubRepository;
/** Delete a GitHub repository by ID in the background */
scheduleGitHubRepositoryDeletion: BackgroundJobReceipt;
/** Delete a GitHub repository by ID */
deleteGitHubRepository: GitHubRepository;
};
@@ -6145,7 +5923,7 @@ export type GitHubRepositoryMutationCreateGitHubRepositoryArgs = {
};
export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = {
export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = {
githubRepositoryId: Scalars['ID']['input'];
};
@@ -7313,14 +7091,6 @@ 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',
@@ -7500,8 +7270,6 @@ 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 */
@@ -7524,7 +7292,6 @@ 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 */
@@ -7541,8 +7308,6 @@ 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 */
@@ -7589,7 +7354,6 @@ export type RootMutation = {
sentryProject: SentryProjectMutation;
/** Mutations that modify an EAS Submit submission */
submission: SubmissionMutation;
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
update: UpdateMutation;
updateBranch: UpdateBranchMutation;
updateChannel: UpdateChannelMutation;
@@ -7729,7 +7493,6 @@ 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 */
@@ -7942,7 +7705,6 @@ 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 */
@@ -8055,8 +7817,6 @@ 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 */
@@ -8069,11 +7829,6 @@ export type SentryInstallationMutationConfirmPendingSentryInstallationArgs = {
};
export type SentryInstallationMutationDeleteSentryInstallationArgs = {
accountId: Scalars['ID']['input'];
};
export type SentryInstallationMutationGenerateSentryTokenArgs = {
accountId: Scalars['ID']['input'];
};
@@ -8112,13 +7867,6 @@ 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 */
@@ -8478,34 +8226,6 @@ 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;
@@ -8800,10 +8520,8 @@ 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']>;
@@ -8956,7 +8674,6 @@ export type UpdateInsights = {
cumulativeAverageMetrics: CumulativeAverageMetrics;
cumulativeMetrics: CumulativeMetrics;
id: Scalars['ID']['output'];
sizeBreakdownByCategory: Array<SizeBreakdownCategory>;
totalUniqueUsers: Scalars['Int']['output'];
};
@@ -9122,7 +8839,6 @@ export type UsageMetricTotal = {
export enum UsageMetricType {
Bandwidth = 'BANDWIDTH',
Build = 'BUILD',
Credit = 'CREDIT',
Minute = 'MINUTE',
Request = 'REQUEST',
Update = 'UPDATE',
@@ -9187,7 +8903,6 @@ 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 */
@@ -9295,7 +9010,6 @@ 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 */
@@ -10873,10 +10587,6 @@ 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',
@@ -12081,33 +11791,6 @@ 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'];
}>;
@@ -12288,12 +11971,6 @@ 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 }> };
@@ -1,239 +0,0 @@
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,
};
},
};
@@ -1,83 +0,0 @@
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
}
}
}
`;
@@ -20,8 +20,10 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAvailability,
AppleMetadata,
ApplePreviews,
ApplePricing,
AppleScreenshots,
} from '../types';
@@ -257,4 +259,14 @@ export class AppleConfigReader {
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/** Get the pricing block, or null if not configured. */
public getPricing(): ApplePricing | null {
return this.schema.pricing ?? null;
}
/** Get the availability block, or null if not configured. */
public getAvailability(): AppleAvailability | null {
return this.schema.availability ?? null;
}
}
@@ -17,8 +17,10 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleAvailability,
AppleMetadata,
ApplePreviews,
ApplePricing,
AppleScreenshots,
} from '../types';
@@ -241,6 +243,29 @@ export class AppleConfigWriter {
}
}
/** Set the pricing configuration (price tier + scheduled changes). */
public setPricing(pricing: ApplePricing | null): void {
if (!pricing || (pricing.tier == null && !pricing.schedule?.length)) {
delete this.schema.pricing;
return;
}
this.schema.pricing = {
tier: pricing.tier,
schedule: pricing.schedule && pricing.schedule.length > 0 ? pricing.schedule : undefined,
};
}
/** Set the territory availability configuration. */
public setAvailability(availability: AppleAvailability | null): void {
if (!availability || availability.territories == null) {
delete this.schema.availability;
return;
}
this.schema.availability = {
territories: availability.territories,
};
}
/** Set per-locale App Clip info (subtitle + header image). */
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
this.schema.appClip = this.schema.appClip ?? {};
+3 -1
View File
@@ -6,6 +6,7 @@ import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { PreviewsData } from './tasks/previews';
import type { PricingData } from './tasks/pricing';
import type { ScreenshotsData } from './tasks/screenshots';
/**
@@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData;
AppClipData &
PricingData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -0,0 +1,268 @@
import { App, AppPrice, AppPriceTier, Territory } from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PricingTask } from '../pricing';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makePrice(id: string, startDate: string, tierId: string): AppPrice {
const tier = new AppPriceTier(requestContext, tierId, {});
return new AppPrice(requestContext, id, {
startDate,
priceTier: tier,
} as any);
}
function makeTerritory(code: string, currency = 'USD'): Territory {
return new Territory(requestContext, code, { currency });
}
function makeApp(id = 'stub-id'): App {
return new App(requestContext, id, {} as any);
}
describe(PricingTask, () => {
describe('prepareAsync', () => {
it('populates appPrices and availableTerritories from App.infoAsync', async () => {
const app = makeApp();
const fetched = new App(requestContext, 'stub-id', {
prices: [makePrice('p1', '2024-01-01T00:00:00Z', '0')],
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
} as any);
const spy = jest.spyOn(App, 'infoAsync').mockResolvedValue(fetched);
const context: PartialAppleData = { app, projectDir: '/test' };
await new PricingTask().prepareAsync({ context });
expect(spy).toHaveBeenCalledWith(
app.context,
expect.objectContaining({
id: 'stub-id',
query: expect.objectContaining({
includes: expect.arrayContaining(['prices', 'availableTerritories']),
}),
})
);
expect(context.appPrices).toHaveLength(1);
expect(context.availableTerritories).toHaveLength(2);
spy.mockRestore();
});
it('initializes empty arrays when the API call fails', async () => {
const spy = jest
.spyOn(App, 'infoAsync')
.mockRejectedValue(new Error('legacy pricing endpoint not available'));
const context: PartialAppleData = { app: makeApp(), projectDir: '/test' };
await new PricingTask().prepareAsync({ context });
expect(context.appPrices).toEqual([]);
expect(context.availableTerritories).toEqual([]);
spy.mockRestore();
});
});
describe('downloadAsync', () => {
it('clears pricing and availability when the app has neither', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: { appPrices: [], availableTerritories: [] } as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(null);
expect(writer.setAvailability).toHaveBeenCalledWith(null);
});
it('writes the current price tier (free)', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0')],
availableTerritories: [],
} as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(
expect.objectContaining({ tier: '0', schedule: undefined })
);
});
it('writes future price changes as schedule entries', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const future = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString();
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0'), makePrice('p2', future, '5')],
availableTerritories: [],
} as any,
});
expect(writer.setPricing).toHaveBeenCalledWith(
expect.objectContaining({
tier: '0',
schedule: [{ startDate: future, tier: '5' }],
})
);
});
it('writes territory codes', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PricingTask().downloadAsync({
config: writer,
context: {
appPrices: [],
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
} as any,
});
expect(writer.setAvailability).toHaveBeenCalledWith({ territories: ['USA', 'GBR'] });
});
});
describe('uploadAsync', () => {
it('skips when neither pricing nor availability is configured', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).not.toHaveBeenCalled();
});
it('pushes the configured price tier', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ pricing: { tier: '0' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: '0',
territories: undefined,
});
});
it('pushes a paid price tier', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ pricing: { tier: '5' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '5', territories: undefined });
});
it('warns and skips push for scheduled price changes (pending expo/third-party#147)', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
pricing: {
tier: '0',
schedule: [{ startDate: '2099-01-01T00:00:00Z', tier: '5' }],
},
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
// Tier still gets pushed; schedule is logged as a warning.
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '0', territories: undefined });
warnSpy.mockRestore();
});
it('pushes a single-territory availability list', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ availability: { territories: ['USA'] } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA'],
});
});
it('pushes multiple territories, normalized and deduplicated', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
availability: { territories: ['usa', 'GBR', 'usa', 'JPN'] },
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA', 'GBR', 'JPN'],
});
});
it('expands `all` to every supported territory', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
const territorySpy = jest
.spyOn(Territory, 'getAsync')
.mockResolvedValue([
makeTerritory('USA'),
makeTerritory('GBR', 'GBP'),
makeTerritory('JPN', 'JPY'),
]);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({ availability: { territories: 'all' } }),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(territorySpy).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: undefined,
territories: ['USA', 'GBR', 'JPN'],
});
territorySpy.mockRestore();
});
it('combines pricing and availability into a single update call', async () => {
const app = makeApp();
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
await new PricingTask().uploadAsync({
config: new AppleConfigReader({
pricing: { tier: '0' },
availability: { territories: ['USA', 'GBR'] },
}),
context: { app, appPrices: [], availableTerritories: [] } as any,
});
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledWith({
appPriceTier: '0',
territories: ['USA', 'GBR'],
});
});
});
});
@@ -4,6 +4,7 @@ import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { PreviewsTask } from './previews';
import { PricingTask } from './pricing';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -23,5 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new PricingTask(),
];
}
@@ -0,0 +1,226 @@
import { App, AppPrice, Territory } from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { ApplePriceScheduleEntry, AppleTerritoryCode } from '../types';
/**
* Sentinel territory list meaning "make the app available in every supported
* App Store territory". Resolved at upload time against
* `Territory.getAsync` so we don't drift if Apple adds new territories.
*/
export const AVAILABILITY_ALL = 'all' as const;
export type PricingData = {
/**
* Current `AppPrice` records for the app. Each record encodes a (startDate,
* priceTier) pair — the active price tier is the most recent entry whose
* startDate is in the past.
*/
appPrices: AppPrice[];
/** Territories the app is currently available in. */
availableTerritories: Territory[];
};
/**
* Task for managing app pricing (price tier + scheduled changes) and
* territory availability. Apple groups these together because both are set
* via the legacy `App` PATCH endpoint that `@expo/apple-utils` exposes via
* `App.updateAsync({ appPriceTier, territories })`.
*
* Newer App Store Connect APIs (`appPriceSchedules`, `appAvailabilities`)
* supersede this for accounts on the post-2023 base-territory pricing model,
* but those endpoints are not yet wrapped by `@expo/apple-utils`. The reader
* accepts a `pricing.schedule` block that we can wire up once the new
* helpers land.
*
* TODO(expo/third-party#147): Once `@expo/apple-utils` is bumped to include
* the new pricing helpers, wire up schedule push:
*
* - Use `AppPriceSchedule.createAsync(context, { appId, baseTerritoryId, manualPrices })`
* to push the full schedule. Each `manualPrices` entry takes
* `{ appPricePointId, startDate?, endDate? }`.
*
* - IMPORTANT: `AppPriceSchedule.createAsync` REPLACES the entire prior
* schedule. When pushing, the full schedule from config must be sent --
* omitting entries will remove them from App Store Connect. There is no
* diff/merge behavior.
*
* - Use `AppPricePoint.getForAppAsync(context, appId, { query })` to
* resolve tier names to `appPricePointId` values, filterable by territory.
*
* - Use `App.getPriceScheduleAsync()` to read the current schedule (singular
* relationship: `apps/{id}/appPriceSchedule`).
*
* - Territory availability is separate from pricing in the modern API --
* it's managed via `TerritoryAvailability`, not via price schedules.
* The legacy `App.updateAsync({ territories })` path used below still
* works for setting availability.
*/
export class PricingTask extends AppleTask {
public name = (): string => 'pricing and availability';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.appPrices = [];
context.availableTerritories = [];
// Pull the current `prices` and `availableTerritories` relationships
// onto the in-memory App object. We refetch via `infoAsync` because the
// App used elsewhere in the flow is loaded without these includes.
let appWithPricing: App;
try {
appWithPricing = await App.infoAsync(context.app.context, {
id: context.app.id,
query: {
includes: ['prices', 'prices.priceTier', 'availableTerritories'],
},
});
} catch (error: any) {
// Pricing/availability are not always retrievable for every app state
// (e.g. apps removed from sale, or accounts on the new pricing model
// that 404 the legacy endpoints). Don't block the rest of the sync.
Log.warn(
chalk`{yellow Skipped pricing/availability prepare - failed to load: ${error?.message ?? error}}`
);
return;
}
context.appPrices = appWithPricing.attributes.prices ?? [];
context.availableTerritories = appWithPricing.attributes.availableTerritories ?? [];
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
// Pricing → schema.pricing
const sortedPrices = [...(context.appPrices ?? [])].sort((a, b) =>
(a.attributes.startDate ?? '').localeCompare(b.attributes.startDate ?? '')
);
if (sortedPrices.length === 0) {
config.setPricing(null);
} else {
// The "current" tier is the most recent entry whose startDate is in
// the past (or the earliest entry if everything is in the future).
const now = new Date().toISOString();
const past = sortedPrices.filter(p => (p.attributes.startDate ?? '') <= now);
const currentEntry = past.length > 0 ? past[past.length - 1] : sortedPrices[0];
const future = sortedPrices.filter(p => (p.attributes.startDate ?? '') > now);
const schedule: ApplePriceScheduleEntry[] = future.map(p => ({
startDate: p.attributes.startDate,
tier: p.attributes.priceTier?.id ?? '',
}));
config.setPricing({
tier: currentEntry.attributes.priceTier?.id,
schedule: schedule.length > 0 ? schedule : undefined,
});
}
// Availability → schema.availability
const territories = context.availableTerritories ?? [];
if (territories.length === 0) {
config.setAvailability(null);
} else {
config.setAvailability({
territories: territories.map(t => t.id),
});
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const pricing = config.getPricing();
const availability = config.getAvailability();
if (!pricing && !availability) {
Log.log(chalk`{dim - Skipped pricing and availability, not configured}`);
return;
}
if (pricing?.schedule && pricing.schedule.length > 0) {
// TODO(expo/third-party#147): Replace this warning with a call to
// AppPriceSchedule.createAsync once @expo/apple-utils is bumped.
// Remember: createAsync REPLACES the entire schedule, so the full
// config must be sent (not just the diff).
Log.warn(
chalk`{yellow pricing.schedule is not yet pushed. Scheduled price changes require a newer @expo/apple-utils with AppPriceSchedule support (see expo/third-party#147). Only pricing.tier is applied.}`
);
}
// Territory availability is set via the legacy App.updateAsync({ territories })
// endpoint below. The modern ASC API uses TerritoryAvailability as a separate
// resource from pricing (not part of appPriceSchedules). The legacy path works
// for both old and new pricing-model accounts.
let resolvedTerritories: AppleTerritoryCode[] | undefined;
if (availability?.territories) {
resolvedTerritories = await resolveTerritoriesAsync({
context,
desired: availability.territories,
});
}
const appPriceTier = pricing?.tier;
if (appPriceTier == null && resolvedTerritories == null) {
// Nothing to do — both blocks were configured but neither set a value
// we can act on (e.g. only `pricing.schedule` was provided).
return;
}
const summary: string[] = [];
if (appPriceTier != null) {
summary.push(`tier ${chalk.bold(appPriceTier)}`);
}
if (resolvedTerritories != null) {
summary.push(`${chalk.bold(resolvedTerritories.length)} territories`);
}
context.app = await logAsync(
() =>
context.app.updateAsync({
appPriceTier,
territories: resolvedTerritories,
}),
{
pending: `Updating pricing and availability (${summary.join(', ')})...`,
success: `Updated pricing and availability (${summary.join(', ')})`,
failure: 'Failed to update pricing and availability',
}
);
}
}
/**
* Resolve a desired territory list (either an explicit code list or the
* `'all'` sentinel) into an array of ISO territory codes the App Store
* Connect API will accept.
*/
async function resolveTerritoriesAsync({
context,
desired,
}: {
context: { app: App };
desired: AppleTerritoryCode[] | typeof AVAILABILITY_ALL;
}): Promise<AppleTerritoryCode[]> {
if (desired === AVAILABILITY_ALL) {
const territories = await Territory.getAsync(context.app.context, {
query: { limit: 200 },
});
return territories.map(t => t.id);
}
// Deduplicate, drop empties, and uppercase to match ASC's canonical form.
const seen = new Set<string>();
const out: string[] = [];
for (const code of desired) {
if (!code) {
continue;
}
const normalized = code.toUpperCase();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
out.push(normalized);
}
return out;
}
@@ -54,8 +54,65 @@ export interface AppleMetadata {
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/** Pricing configuration for the app (price tier, scheduled changes). */
pricing?: ApplePricing;
/** Territory availability configuration for the app. */
availability?: AppleAvailability;
}
/**
* Pricing configuration for an app.
*
* NOTE: Apple migrated to "base territory" pricing in 2023, replacing the
* legacy global price tier model. The App Store Connect API still exposes a
* `priceTier` concept on the legacy `appPrices`/`appPriceTiers` resources,
* which is what `@expo/apple-utils` currently exposes via
* `App.updateAsync({ appPriceTier })`. Newer apps may need to use
* `appPriceSchedules` + `appPricePoints` (which require selecting a base
* territory and a price point id), but those endpoints are not yet wrapped by
* `@expo/apple-utils`. We use the legacy field for now and document this in
* the PR. The schema is intentionally forward-compatible.
*/
export interface ApplePricing {
/**
* App Store price tier (e.g. `'0'` for free, `'1'` for tier 1). When
* unset on a brand new app, the App Store defaults to free (tier 0).
*/
tier?: string;
/**
* Future scheduled price changes. Each entry switches the app to the
* given tier on `startDate`. The first entry that matches "now" or earlier
* is treated as the current price. This block is currently parsed but not
* pushed (the legacy `App.updateAsync` endpoint exposed by
* `@expo/apple-utils` only sets the active tier).
*/
schedule?: ApplePriceScheduleEntry[];
}
export interface ApplePriceScheduleEntry {
/** ISO-8601 date the new price tier takes effect. */
startDate: string;
/** App Store price tier id (e.g. `'0'` for free). */
tier: string;
}
/**
* Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g.
* `"USA"`, `"GBR"`, `"JPN"`) — these match the App Store Connect Territory
* resource ids.
*/
export interface AppleAvailability {
/**
* The territories the app is available in. Use the literal string `'all'`
* to make the app available worldwide (every territory currently supported
* by App Store Connect). Omitting this field leaves availability untouched.
*/
territories?: AppleTerritoryCode[] | 'all';
}
/** ISO-3166 alpha-3 territory code (e.g. `"USA"`, `"GBR"`). */
export type AppleTerritoryCode = string;
/** App Clip action enum values from App Store Connect API */
export type AppleAppClipAction = `${AppClipAction}`;
@@ -1,276 +0,0 @@
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');
});
});
@@ -1,270 +0,0 @@
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);
});
});
@@ -1,189 +0,0 @@
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(`
"Value App Version Platform Device Country Timestamp 
----- ----------- ---------- --------- ------- ----------------------
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 });
});
});
@@ -1,360 +0,0 @@
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(`
"Med values (event count)
iOS
App Version Cold Launch TTI 
----------- ----------- ----------
1.2.0 0.35s (110) 1.32s (90)
Android
App Version Cold Launch TTI 
----------- ----------- -----------
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(`
"Med values (event count)
iOS
App Version Cold Launch TTI 
----------- ----------- -----
2.0.0 - (-) - (-)"
`);
});
it('returns message when no metrics data found', () => {
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
expect(output).toMatchInlineSnapshot(`"No metrics data found."`);
});
});
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,
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,
});
});
});
@@ -1,52 +0,0 @@
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');
});
});
-120
View File
@@ -1,120 +0,0 @@
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);
}
@@ -1,114 +0,0 @@
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 };
}
@@ -1,41 +0,0 @@
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);
}
@@ -1,140 +0,0 @@
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,
},
};
}
@@ -1,294 +0,0 @@
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;
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>
): 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, 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;
updateIdsMap?: UpdateIdsMap;
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]}`);
}
}
}
// Check if any version has updates
const hasUpdates = options?.updateIdsMap
? Array.from(options.updateIdsMap.values()).some(ids => ids.length > 0)
: false;
const headers = ['App Version', ...(hasUpdates ? ['Updates'] : []), ...metricHeaders];
const sections: string[] = [chalk.bold(summaryLine)];
for (const [platform, platformResults] of byPlatform) {
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
const rows: string[][] = platformResults.map(result => {
const key = makeMetricsKey(result.appVersion, result.platform);
const buildNumbers = options?.buildNumbersMap?.get(key);
const versionLabel = buildNumbers?.length
? `${result.appVersion} (${buildNumbers.join(', ')})`
: result.appVersion;
const updateIds = options?.updateIdsMap?.get(key);
const updatesLabel = updateIds?.length ? updateIds.join(', ') : '';
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));
}
}
}
return [versionLabel, ...(hasUpdates ? [updatesLabel] : []), ...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', ...(hasUpdates ? [''] : []), ...countCells];
}
}
sections.push(renderTable(headers, rows, footerRow));
}
return sections.join('\n');
}
@@ -1,142 +0,0 @@
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');
}
@@ -1,35 +0,0 @@
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;
}
@@ -1,48 +0,0 @@
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 };
}
+7 -17
View File
@@ -203,7 +203,6 @@ export async function* batchUploadAsync(
(() => {
onProgressUpdate(getProgressValue());
});
let firstError: unknown = null;
try {
let index = 0;
while (index < payloads.length || queue.size > 0) {
@@ -216,38 +215,29 @@ export async function* batchUploadAsync(
progressTracker[currentIndex] = progress;
sendProgressUpdate();
});
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).then(
result => {
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).finally(
() => {
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') {
firstError ??= error;
throw error;
}
}
if (firstError) {
throw firstError;
}
}
interface UploadProgressBar {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.6.0",
"version": "18.5.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.6.0",
"@expo/eas-build-job": "18.5.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.7.0",
"version": "18.5.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.7.0",
"@expo/eas-build-job": "18.6.0",
"@expo/build-tools": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.5.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.6.0",
"version": "18.5.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.6.0",
"@expo/eas-build-job": "18.5.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+1 -6
View File
@@ -188,12 +188,7 @@ const BuildFunctionCallSchema = Joi.object({
name: Joi.string(),
workingDirectory: Joi.string(),
shell: Joi.string(),
env: Joi.object().pattern(
Joi.string(),
Joi.alternatives()
.try(Joi.number().strict(), Joi.string().allow(''))
.custom(value => String(value))
),
env: Joi.object().pattern(Joi.string(), Joi.string().allow('')),
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 one of \[number, string\]/);
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
});
test('invalid env type', () => {
const buildConfig = {
@@ -278,48 +278,7 @@ describe(validateConfig, () => {
expect(() => {
validateConfig(BuildConfigSchema, buildConfig);
}).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' });
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
});
test('valid timeout_minutes', () => {
const buildConfig = {
@@ -512,36 +471,6 @@ 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: {
@@ -589,7 +518,7 @@ describe(validateConfig, () => {
expect(() => {
validateConfig(BuildConfigSchema, buildConfig);
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
});
test('invalid env structure', () => {
const buildConfig = {
@@ -615,7 +544,7 @@ describe(validateConfig, () => {
expect(() => {
validateConfig(BuildConfigSchema, buildConfig);
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
});
test('call with inputs boolean', () => {
const buildConfig = {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.7.0",
"version": "18.5.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.7.0",
"@expo/build-tools": "18.5.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.6.0",
"@expo/eas-build-job": "18.5.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.6.0",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@hapi/boom": "10.0.1",
Generated
+17 -17
View File
@@ -2856,14 +2856,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.7.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.5.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.6.0"
"@expo/eas-build-job": "npm:18.5.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.6.0"
"@expo/steps": "npm:18.5.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.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:18.5.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.6.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:18.5.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.6.0"
"@expo/eas-build-job": "npm:18.5.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.6.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:18.5.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.6.0"
"@expo/eas-build-job": "npm:18.5.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.7.0"
"@expo/build-tools": "npm:18.5.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.6.0"
"@expo/eas-build-job": "npm:18.5.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.6.0"
"@expo/steps": "npm:18.5.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.6.0"
"@expo/steps": "npm:18.5.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.7.0"
"@expo/eas-build-job": "npm:18.6.0"
"@expo/build-tools": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.5.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.6.0"
"@expo/eas-json": "npm:18.6.0"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/eas-json": "npm:18.5.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.6.0"
"@expo/steps": "npm:18.5.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"