mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb6dfc334 | ||
|
|
dd84646768 | ||
|
|
c3fbf63a71 | ||
|
|
7c0d7b60c7 | ||
|
|
5a8e1b4ba3 | ||
|
|
12b648a603 |
No files matched your search
+4
-1
@@ -10,15 +10,17 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [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))
|
||||
- [eas-cli] Allow up to 3 video previews per device type in `metadata:push` and `metadata:pull`. Each preview type now accepts an array of preview configs in addition to the existing single-config form (which remains supported for backwards compatibility). ([@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))
|
||||
- [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
|
||||
|
||||
@@ -28,6 +30,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createEasBuildBuildFunctionGroup } from '../build';
|
||||
|
||||
function createMockBuildToolsContext(
|
||||
overrides: Partial<{
|
||||
platform: Platform;
|
||||
simulator: boolean;
|
||||
buildCredentials: Record<string, unknown>;
|
||||
}> = {}
|
||||
): CustomBuildContext<BuildJob> {
|
||||
return {
|
||||
job: {
|
||||
platform: overrides.platform ?? Platform.ANDROID,
|
||||
simulator: overrides.simulator ?? false,
|
||||
secrets: overrides.buildCredentials
|
||||
? { buildCredentials: overrides.buildCredentials }
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
}
|
||||
|
||||
describe(createEasBuildBuildFunctionGroup, () => {
|
||||
describe('working_directory input', () => {
|
||||
it('does not set working directory on steps when not provided (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
for (const step of steps) {
|
||||
// Without working_directory, only installPods would have a relative dir (iOS only).
|
||||
// On Android, no step should have a relative working directory.
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS simulator)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
|
||||
});
|
||||
|
||||
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for generic jobs (no platform)', () => {
|
||||
const buildToolsContext = {
|
||||
job: { platform: undefined },
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -199,11 +250,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -215,24 +271,29 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -242,6 +303,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -249,6 +311,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -257,6 +320,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -264,18 +328,26 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -286,24 +358,29 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -313,6 +390,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -320,6 +398,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -328,6 +407,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -335,21 +415,33 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -359,8 +451,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["jest", "node"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gql.tada/ts-plugin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.5.0"
|
||||
"@expo/steps": "18.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -84,6 +84,232 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with app_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'ready_for_review',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect app_version state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with external_beta', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'beta_approved',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect external_beta state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with beta_feedback', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'screenshot',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects app_store_connect beta_feedback without url', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'crash',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect beta_feedback type', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'invalid-type',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect build_upload context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -226,6 +226,66 @@ 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(),
|
||||
@@ -259,19 +319,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
app_store_connect: z
|
||||
.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
|
||||
app_store_connect: AppStoreConnectContextZ.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
+88
-88
@@ -178,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +422,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +442,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +460,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -501,7 +501,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,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.5.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,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.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,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.5.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,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.5.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,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.5.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,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.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,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.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,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.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,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.5.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,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.5.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,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.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,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.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,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.5.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,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.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,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.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,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.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,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.5.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,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.5.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +985,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,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.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,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.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,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.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,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.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,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.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,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.5.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,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.5.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,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.5.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,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.5.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,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.5.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1226,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,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.5.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,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.5.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,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.5.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,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.5.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,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.5.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,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.5.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,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.5.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,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.5.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,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.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,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.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,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.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1643,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.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1661,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.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1713,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.5.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1727,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.5.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1750,7 +1750,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1774,7 +1774,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1792,7 +1792,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1825,7 +1825,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1868,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.5.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1889,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.5.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1910,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.5.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1935,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.5.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1962,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.5.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2000,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.5.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2028,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.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2058,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.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2079,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.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2099,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.5.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2120,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.5.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2142,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.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2162,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.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2181,7 +2181,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2204,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.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2221,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.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2342,7 +2342,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2362,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.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2386,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.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2426,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.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2448,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.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2476,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.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2496,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.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2518,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.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.6.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+2868
-545
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-json": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-json": "18.6.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.5.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -204,6 +204,9 @@
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"observe": {
|
||||
"description": "monitor app performance metrics"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
},
|
||||
"previews": {
|
||||
"type": "object",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129). Each device type accepts a single preview config (string or object) or an array of up to 3 preview configs.",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -348,31 +348,6 @@
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Video file path (relative to project root)"
|
||||
},
|
||||
"previewFrameTimeCode": {
|
||||
"type": "string",
|
||||
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
|
||||
export const getLimitFlagWithCustomValues = ({
|
||||
defaultTo,
|
||||
limit,
|
||||
description,
|
||||
}: {
|
||||
defaultTo: number;
|
||||
limit: number;
|
||||
description?: string;
|
||||
}): Interfaces.OptionFlag<number | undefined> =>
|
||||
Flags.integer({
|
||||
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
description:
|
||||
description ??
|
||||
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
|
||||
});
|
||||
|
||||
@@ -362,8 +362,22 @@ export default class WorkerDeploy extends EasCommand {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
await finalizeDeployAsync(deployResult);
|
||||
let uploadError: unknown;
|
||||
try {
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
try {
|
||||
await finalizeDeployAsync(deployResult);
|
||||
} catch (finalizeError) {
|
||||
if (!uploadError) {
|
||||
throw finalizeError;
|
||||
}
|
||||
}
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
|
||||
if (flags.aliasName) {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
|
||||
import { buildObserveEventsJson } from '../../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
|
||||
jest.mock('../../../observe/fetchEvents', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchEvents');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveEventsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveEventsJson: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveEvents, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvents {
|
||||
const command = new ObserveEvents(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'tti',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --limit to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--limit', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('passes --after cursor to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--after', 'cursor-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.after).toBe('cursor-xyz');
|
||||
});
|
||||
|
||||
it('does not pass after when --after flag is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options).not.toHaveProperty('after');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
});
|
||||
|
||||
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('passes --app-version to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--app-version', '2.1.0']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --update-id to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['tti', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
|
||||
mockEvents,
|
||||
expect.objectContaining({ hasNextPage: false })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--sort', 'slowest']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when no metric is provided', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('resolves lowercase "slowest" to MetricValue DESC', () => {
|
||||
expect(resolveOrderBy('slowest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "fastest" to MetricValue ASC', () => {
|
||||
expect(resolveOrderBy('fastest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "newest" to Timestamp DESC', () => {
|
||||
expect(resolveOrderBy('newest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "oldest" to Timestamp ASC', () => {
|
||||
expect(resolveOrderBy('oldest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
|
||||
import {
|
||||
buildObserveVersionsJson,
|
||||
buildObserveVersionsTable,
|
||||
} from '../../../observe/formatVersions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveVersions from '../versions';
|
||||
|
||||
jest.mock('../../../observe/fetchVersions');
|
||||
jest.mock('../../../observe/formatVersions', () => ({
|
||||
buildObserveVersionsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveVersionsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveVersionsAsync = jest.mocked(fetchObserveVersionsAsync);
|
||||
const mockBuildObserveVersionsTable = jest.mocked(buildObserveVersionsTable);
|
||||
const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveVersions, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveVersions {
|
||||
const command = new ObserveVersions(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches versions with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes the context project ID to fetchObserveVersionsAsync by default', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('uses --project-id flag when provided', async () => {
|
||||
const command = createCommand(['--project-id', 'override-project-id']);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe('override-project-id');
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([
|
||||
{
|
||||
platform: AppPlatform.Ios,
|
||||
appVersions: [
|
||||
{
|
||||
appVersion: '1.0.0',
|
||||
firstSeenAt: '2025-06-01T00:00:00.000Z',
|
||||
eventCount: 100,
|
||||
uniqueUserCount: 50,
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveVersionsJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls buildObserveVersionsTable for non-json output', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveVersionsTable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+393
-70
@@ -120,6 +120,8 @@ export type Account = {
|
||||
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
|
||||
billing?: Maybe<Billing>;
|
||||
billingPeriod: BillingPeriod;
|
||||
/** Convex team connections for this account */
|
||||
convexTeamConnections: Array<ConvexTeamConnection>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
/** Echo projects for this account (paginated, most recent first) */
|
||||
@@ -155,6 +157,8 @@ export type Account = {
|
||||
name: Scalars['String']['output'];
|
||||
/** Offers set on this account */
|
||||
offers?: Maybe<Array<Offer>>;
|
||||
/** Onboarding milestone stats for this account */
|
||||
onboardingStats: AccountOnboardingStats;
|
||||
/**
|
||||
* Owning User of this account if personal account
|
||||
* @deprecated Deprecated in favor of ownerUserActor
|
||||
@@ -767,6 +771,15 @@ export type AccountNotificationPreferenceInput = {
|
||||
type: NotificationType;
|
||||
};
|
||||
|
||||
/** Onboarding milestone stats for an account */
|
||||
export type AccountOnboardingStats = {
|
||||
__typename?: 'AccountOnboardingStats';
|
||||
firstBuildCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
firstSubmissionCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
hasConfiguredUpdate: Scalars['Boolean']['output'];
|
||||
hasTeamMembers: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type AccountQuery = {
|
||||
__typename?: 'AccountQuery';
|
||||
/** Query an Account by ID */
|
||||
@@ -1116,7 +1129,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = {
|
||||
|
||||
|
||||
export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
androidAppCredentialsId: Scalars['String']['input'];
|
||||
credential: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1358,6 +1370,7 @@ export type App = Project & {
|
||||
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
|
||||
/** Android app credentials for the project */
|
||||
androidAppCredentials: Array<AndroidAppCredentials>;
|
||||
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
|
||||
/**
|
||||
* ios.appStoreUrl field from most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
@@ -1490,6 +1503,7 @@ export type App = Project & {
|
||||
timelineActivity: TimelineActivityConnection;
|
||||
/** @deprecated 'likes' have been deprecated. */
|
||||
trendScore: Scalars['Float']['output'];
|
||||
turtleBrownfieldArtifactsPaginated: BrownfieldArtifactsConnection;
|
||||
/** get an EAS branch owned by the app by name */
|
||||
updateBranchByName?: Maybe<UpdateBranch>;
|
||||
/** EAS branches owned by an app */
|
||||
@@ -1708,6 +1722,16 @@ export type AppTimelineActivityArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppTurtleBrownfieldArtifactsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<BrownfieldArtifactFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppUpdateBranchByNameArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
@@ -2050,14 +2074,19 @@ export type AppNotificationPreferenceInput = {
|
||||
|
||||
export type AppObserve = {
|
||||
__typename?: 'AppObserve';
|
||||
appVersions: Array<AppObserveAppVersion>;
|
||||
environments: Array<Scalars['String']['output']>;
|
||||
events: AppObserveEventsConnection;
|
||||
releases: Array<AppObserveRelease>;
|
||||
timeSeries: AppObserveTimeSeries;
|
||||
totalEventCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveAppVersionsArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveEnvironmentsArgs = {
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
@@ -2075,15 +2104,54 @@ export type AppObserveEventsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveReleasesArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveTimeSeriesArgs = {
|
||||
input: AppObserveTimeSeriesInput;
|
||||
};
|
||||
|
||||
export type AppObserveAppBuildNumber = {
|
||||
__typename?: 'AppObserveAppBuildNumber';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppEasBuild = {
|
||||
__typename?: 'AppObserveAppEasBuild';
|
||||
easBuildId: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppUpdate = {
|
||||
__typename?: 'AppObserveAppUpdate';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppVersion = {
|
||||
__typename?: 'AppObserveAppVersion';
|
||||
appVersion: Scalars['String']['output'];
|
||||
buildNumbers: Array<AppObserveAppBuildNumber>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
metrics: Array<AppObserveAppVersionMetric>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
updates: Array<AppObserveAppUpdate>;
|
||||
};
|
||||
|
||||
export type AppObserveAppVersionMetric = {
|
||||
__typename?: 'AppObserveAppVersionMetric';
|
||||
eventCount: Scalars['Int']['output'];
|
||||
metricName: Scalars['String']['output'];
|
||||
statistics: AppObserveVersionMarkerStatistics;
|
||||
};
|
||||
|
||||
export type AppObserveEvent = {
|
||||
__typename?: 'AppObserveEvent';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
@@ -2093,6 +2161,7 @@ export type AppObserveEvent = {
|
||||
appVersion: Scalars['String']['output'];
|
||||
clientVersion?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
customParams?: Maybe<Scalars['JSON']['output']>;
|
||||
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
|
||||
deviceModel: Scalars['String']['output'];
|
||||
deviceName?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2129,6 +2198,8 @@ export type AppObserveEventsConnection = {
|
||||
};
|
||||
|
||||
export type AppObserveEventsFilter = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2160,35 +2231,22 @@ export enum AppObservePlatform {
|
||||
Ios = 'IOS'
|
||||
}
|
||||
|
||||
export type AppObserveRelease = {
|
||||
__typename?: 'AppObserveRelease';
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
type: AppObserveReleaseType;
|
||||
};
|
||||
|
||||
export enum AppObserveReleaseType {
|
||||
Update = 'UPDATE',
|
||||
Version = 'VERSION'
|
||||
}
|
||||
|
||||
export type AppObserveReleasesInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
platform: AppObservePlatform;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeries = {
|
||||
__typename?: 'AppObserveTimeSeries';
|
||||
appVersionMarkers: Array<AppObserveAppVersion>;
|
||||
buckets: Array<AppObserveTimeSeriesBucket>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
latestVersionStatistics?: Maybe<AppObserveVersionStatistics>;
|
||||
previousVersionStatistics?: Maybe<AppObserveVersionStatistics>;
|
||||
statistics: AppObserveTimeSeriesStatistics;
|
||||
updateMarkers: Array<AppObserveUpdateMarker>;
|
||||
versionMarkers: Array<AppObserveVersionMarker>;
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeriesBucket = {
|
||||
@@ -2205,6 +2263,8 @@ export type AppObserveTimeSeriesBucket = {
|
||||
};
|
||||
|
||||
export type AppObserveTimeSeriesInput = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -2227,22 +2287,6 @@ export type AppObserveTimeSeriesStatistics = {
|
||||
p99?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export type AppObserveUpdateMarker = {
|
||||
__typename?: 'AppObserveUpdateMarker';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
appVersion: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveVersionMarker = {
|
||||
__typename?: 'AppObserveVersionMarker';
|
||||
appVersion: Scalars['String']['output'];
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
statistics: AppObserveVersionMarkerStatistics;
|
||||
};
|
||||
|
||||
export type AppObserveVersionMarkerStatistics = {
|
||||
__typename?: 'AppObserveVersionMarkerStatistics';
|
||||
average?: Maybe<Scalars['Float']['output']>;
|
||||
@@ -2376,10 +2420,16 @@ export type AppStoreConnectApiKey = {
|
||||
keyIdentifier: Scalars['String']['output'];
|
||||
keyP8: Scalars['String']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
remoteAppStoreConnectApps: Array<RemoteAppStoreConnectApp>;
|
||||
roles?: Maybe<Array<AppStoreConnectUserRole>>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectApiKeyRemoteAppStoreConnectAppsArgs = {
|
||||
bundleIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AppStoreConnectApiKeyInput = {
|
||||
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
|
||||
issuerIdentifier: Scalars['String']['input'];
|
||||
@@ -2430,6 +2480,43 @@ export type AppStoreConnectApiKeyUpdateInput = {
|
||||
appleTeamId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type AppStoreConnectApp = {
|
||||
__typename?: 'AppStoreConnectApp';
|
||||
app: App;
|
||||
appStoreConnectApiKey: AppStoreConnectApiKey;
|
||||
ascAppIdentifier: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
remoteAppStoreConnectApp: RemoteAppStoreConnectApp;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
webhookEventTypes: Array<Scalars['String']['output']>;
|
||||
webhookIdentifier: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type AppStoreConnectAppInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
appStoreConnectApiKeyId: Scalars['ID']['input'];
|
||||
ascAppIdentifier: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppStoreConnectAppMutation = {
|
||||
__typename?: 'AppStoreConnectAppMutation';
|
||||
/** Create an App Store Connect app for an Expo app. */
|
||||
createAppStoreConnectApp: AppStoreConnectApp;
|
||||
/** Delete an App Store Connect app by ID. */
|
||||
deleteAppStoreConnectApp: DeleteAppStoreConnectAppResult;
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectAppMutationCreateAppStoreConnectAppArgs = {
|
||||
appStoreConnectAppInput: AppStoreConnectAppInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = {
|
||||
appStoreConnectAppId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum AppStoreConnectUserRole {
|
||||
AccessToReports = 'ACCESS_TO_REPORTS',
|
||||
AccountHolder = 'ACCOUNT_HOLDER',
|
||||
@@ -3278,6 +3365,36 @@ export type BranchQueryByIdArgs = {
|
||||
branchId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type BrownfieldArtifact = {
|
||||
__typename?: 'BrownfieldArtifact';
|
||||
bundleName: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
platform: AppPlatform;
|
||||
sizeBytes?: Maybe<Scalars['Int']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactEdge = {
|
||||
__typename?: 'BrownfieldArtifactEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: BrownfieldArtifact;
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactFilterInput = {
|
||||
bundleName?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
version?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type BrownfieldArtifactsConnection = {
|
||||
__typename?: 'BrownfieldArtifactsConnection';
|
||||
edges: Array<BrownfieldArtifactEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
/** Represents an EAS Build */
|
||||
export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
__typename?: 'Build';
|
||||
@@ -3921,13 +4038,6 @@ export type CodeSigningInfoInput = {
|
||||
sig: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
/** Metadata to set when completing a message. */
|
||||
export type CompleteEchoMessageMetadataInput = {
|
||||
cost?: InputMaybe<Scalars['Float']['input']>;
|
||||
finishReason?: InputMaybe<Scalars['String']['input']>;
|
||||
tokens?: InputMaybe<EchoMessageTokenUsageInput>;
|
||||
};
|
||||
|
||||
export type Concurrencies = {
|
||||
__typename?: 'Concurrencies';
|
||||
android: Scalars['Int']['output'];
|
||||
@@ -3951,6 +4061,31 @@ export type ConvexIntegrationQuery = {
|
||||
clientIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConvexTeamConnection = {
|
||||
__typename?: 'ConvexTeamConnection';
|
||||
account: Account;
|
||||
convexTeamIdentifier: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type ConvexTeamConnectionMutation = {
|
||||
__typename?: 'ConvexTeamConnectionMutation';
|
||||
createConvexTeamConnection: ConvexTeamConnection;
|
||||
deleteConvexTeamConnection: ConvexTeamConnection;
|
||||
};
|
||||
|
||||
|
||||
export type ConvexTeamConnectionMutationCreateConvexTeamConnectionArgs = {
|
||||
convexTeamConnectionData: CreateConvexTeamConnectionInput;
|
||||
};
|
||||
|
||||
|
||||
export type ConvexTeamConnectionMutationDeleteConvexTeamConnectionArgs = {
|
||||
convexTeamConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum CrashSampleFor {
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST'
|
||||
@@ -3986,12 +4121,41 @@ export type CreateAndroidSubmissionInput = {
|
||||
submittedBuildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type CreateBrownfieldArtifactInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
bundleName: Scalars['String']['input'];
|
||||
/** Filename for the artifact (e.g. "MyFramework.xcframework.tar.gz"). */
|
||||
filename: Scalars['String']['input'];
|
||||
platform: AppPlatform;
|
||||
/** The ID of the turtle job run that is producing this artifact. */
|
||||
producingTurtleJobRunId: Scalars['ID']['input'];
|
||||
/** Size of the artifact in bytes. Required for generating the signed upload URL. */
|
||||
size: Scalars['Int']['input'];
|
||||
/** Optional. If omitted, the version is auto-incremented from the latest artifact for the same platform and bundle name. */
|
||||
version?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type CreateBrownfieldArtifactResult = {
|
||||
__typename?: 'CreateBrownfieldArtifactResult';
|
||||
artifact: BrownfieldArtifact;
|
||||
/** Headers to include with the upload request. */
|
||||
uploadHeaders: Scalars['JSONObject']['output'];
|
||||
/** Signed URL to upload the artifact to. */
|
||||
uploadUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type CreateBuildResult = {
|
||||
__typename?: 'CreateBuildResult';
|
||||
build: Build;
|
||||
deprecationInfo?: Maybe<EasBuildDeprecationInfo>;
|
||||
};
|
||||
|
||||
export type CreateConvexTeamConnectionInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
convexTeamName?: InputMaybe<Scalars['String']['input']>;
|
||||
deploymentRegion: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateEchoChatInput = {
|
||||
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
agentType?: InputMaybe<EchoAgentType>;
|
||||
@@ -4002,10 +4166,12 @@ export type CreateEchoChatInput = {
|
||||
|
||||
export type CreateEchoMessageInput = {
|
||||
echoChatId: Scalars['ID']['input'];
|
||||
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
id?: InputMaybe<Scalars['ID']['input']>;
|
||||
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
parentEchoMessageId?: InputMaybe<Scalars['ID']['input']>;
|
||||
role: EchoMessageRole;
|
||||
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
|
||||
turnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
userId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
@@ -4056,6 +4222,7 @@ export type CreateEchoVersionInput = {
|
||||
diffs: Scalars['JSON']['input'];
|
||||
echoChatId?: InputMaybe<Scalars['ID']['input']>;
|
||||
echoProjectId: Scalars['ID']['input'];
|
||||
echoTurnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
gitBranch?: InputMaybe<Scalars['String']['input']>;
|
||||
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
|
||||
gitCommitMessage?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -4063,6 +4230,7 @@ export type CreateEchoVersionInput = {
|
||||
revertedFromEchoVersionId?: InputMaybe<Scalars['ID']['input']>;
|
||||
source: EchoVersionSource;
|
||||
thumbnail?: InputMaybe<EchoVersionThumbnailInput>;
|
||||
/** Legacy alias for echoTurnId. Must be a valid UUIDv7. Provide exactly one of echoTurnId or turnId. */
|
||||
turnId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
@@ -4312,6 +4480,11 @@ export type DeleteAndroidKeystoreResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteAppStoreConnectAppResult = {
|
||||
__typename?: 'DeleteAppStoreConnectAppResult';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteAppleDeviceResult = {
|
||||
__typename?: 'DeleteAppleDeviceResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -4394,6 +4567,11 @@ export type DeleteSsoUserResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteSentryInstallationResult = {
|
||||
__typename?: 'DeleteSentryInstallationResult';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteSentryProjectResult = {
|
||||
__typename?: 'DeleteSentryProjectResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -4621,6 +4799,7 @@ export enum EasBuildWaiverType {
|
||||
}
|
||||
|
||||
export enum EasService {
|
||||
Agent = 'AGENT',
|
||||
Builds = 'BUILDS',
|
||||
Jobs = 'JOBS',
|
||||
Mcp = 'MCP',
|
||||
@@ -4631,6 +4810,7 @@ export enum EasServiceMetric {
|
||||
AssetsRequests = 'ASSETS_REQUESTS',
|
||||
BandwidthUsage = 'BANDWIDTH_USAGE',
|
||||
Builds = 'BUILDS',
|
||||
CreditUsage = 'CREDIT_USAGE',
|
||||
LocalBuilds = 'LOCAL_BUILDS',
|
||||
ManifestRequests = 'MANIFEST_REQUESTS',
|
||||
McpRequests = 'MCP_REQUESTS',
|
||||
@@ -4755,24 +4935,19 @@ export type EchoMessage = {
|
||||
echoChat: EchoChat;
|
||||
/** Message parts (text, tool calls, etc.) ordered by index */
|
||||
echoMessageParts: Array<EchoMessagePart>;
|
||||
/** Turn entity for grouping user message + assistant response */
|
||||
echoTurn?: Maybe<EchoTurn>;
|
||||
id: Scalars['ID']['output'];
|
||||
metadata?: Maybe<Scalars['JSONObject']['output']>;
|
||||
/** Parent message (for conversation branching) */
|
||||
parentEchoMessage?: Maybe<EchoMessage>;
|
||||
role: EchoMessageRole;
|
||||
/** Turn ID for grouping user message + assistant response (UUID) */
|
||||
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
|
||||
turnId?: Maybe<Scalars['ID']['output']>;
|
||||
/** User who sent the message (for user messages) */
|
||||
user?: Maybe<User>;
|
||||
};
|
||||
|
||||
/** Breakdown of cache write input tokens by cache TTL. */
|
||||
export type EchoMessageCacheWriteInput = {
|
||||
ttl1h?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl5m?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl24h?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoMessageConnection = {
|
||||
__typename?: 'EchoMessageConnection';
|
||||
edges: Array<EchoMessageEdge>;
|
||||
@@ -4788,7 +4963,10 @@ export type EchoMessageEdge = {
|
||||
|
||||
export type EchoMessageMutation = {
|
||||
__typename?: 'EchoMessageMutation';
|
||||
/** Mark a message as completed (sets completedAt and optionally updates metadata with tokens) */
|
||||
/**
|
||||
* Mark a message as completed (sets completedAt).
|
||||
* metadata is accepted for backward compatibility but ignored — use echoTurn.completeTurn instead.
|
||||
*/
|
||||
completeMessage: EchoMessage;
|
||||
/** Create a new message */
|
||||
createMessage: EchoMessage;
|
||||
@@ -4797,7 +4975,7 @@ export type EchoMessageMutation = {
|
||||
|
||||
export type EchoMessageMutationCompleteMessageArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
metadata?: InputMaybe<CompleteEchoMessageMetadataInput>;
|
||||
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -4888,16 +5066,6 @@ export enum EchoMessageRole {
|
||||
User = 'USER'
|
||||
}
|
||||
|
||||
/** Token usage for billing. Tracks input/output tokens and cache usage. */
|
||||
export type EchoMessageTokenUsageInput = {
|
||||
cacheRead?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWriteBreakdown?: InputMaybe<EchoMessageCacheWriteInput>;
|
||||
input: Scalars['Int']['input'];
|
||||
output: Scalars['Int']['input'];
|
||||
reasoning?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoProject = {
|
||||
__typename?: 'EchoProject';
|
||||
account: Account;
|
||||
@@ -5119,6 +5287,57 @@ export type EchoRepositoryResult = {
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EchoTurn = {
|
||||
__typename?: 'EchoTurn';
|
||||
completedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Parent chat */
|
||||
echoChat: EchoChat;
|
||||
/** Messages in this turn */
|
||||
echoMessages: Array<EchoMessage>;
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
/** Breakdown of cache write input tokens by cache TTL. */
|
||||
export type EchoTurnCacheWriteInput = {
|
||||
ttl1h?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl5m?: InputMaybe<Scalars['Int']['input']>;
|
||||
ttl24h?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type EchoTurnMutation = {
|
||||
__typename?: 'EchoTurnMutation';
|
||||
/** Mark a turn as completed and create a billing ledger entry */
|
||||
completeTurn: EchoTurn;
|
||||
};
|
||||
|
||||
|
||||
export type EchoTurnMutationCompleteTurnArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
usage: EchoTurnUsageInput;
|
||||
};
|
||||
|
||||
/** Token usage for billing. Tracks input/output tokens and cache usage. */
|
||||
export type EchoTurnTokenUsageInput = {
|
||||
cacheRead?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWrite?: InputMaybe<Scalars['Int']['input']>;
|
||||
cacheWriteBreakdown?: InputMaybe<EchoTurnCacheWriteInput>;
|
||||
input: Scalars['Int']['input'];
|
||||
output: Scalars['Int']['input'];
|
||||
reasoning?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
/** Cumulative usage data for a completed turn. */
|
||||
export type EchoTurnUsageInput = {
|
||||
agent?: InputMaybe<Scalars['String']['input']>;
|
||||
cost?: InputMaybe<Scalars['Float']['input']>;
|
||||
finishReason?: InputMaybe<Scalars['String']['input']>;
|
||||
model?: InputMaybe<Scalars['String']['input']>;
|
||||
provider?: InputMaybe<Scalars['String']['input']>;
|
||||
sdkCost?: InputMaybe<Scalars['Float']['input']>;
|
||||
tokens?: InputMaybe<EchoTurnTokenUsageInput>;
|
||||
};
|
||||
|
||||
export type EchoVersion = {
|
||||
__typename?: 'EchoVersion';
|
||||
buildError?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5128,6 +5347,8 @@ export type EchoVersion = {
|
||||
echoChat?: Maybe<EchoChat>;
|
||||
/** Parent project */
|
||||
echoProject: EchoProject;
|
||||
/** Turn that created this version */
|
||||
echoTurn?: Maybe<EchoTurn>;
|
||||
gitBranch: Scalars['String']['output'];
|
||||
gitCommitHash?: Maybe<Scalars['String']['output']>;
|
||||
gitCommitMessage?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5137,7 +5358,7 @@ export type EchoVersion = {
|
||||
revertedFromEchoVersion?: Maybe<EchoVersion>;
|
||||
source: EchoVersionSource;
|
||||
thumbnail?: Maybe<EchoVersionThumbnail>;
|
||||
/** Turn ID that created this version (UUID, optional) */
|
||||
/** Turn ID (legacy alias for echoTurn.id, prefer echoTurn) */
|
||||
turnId?: Maybe<Scalars['ID']['output']>;
|
||||
};
|
||||
|
||||
@@ -5223,7 +5444,7 @@ export enum EntityTypeName {
|
||||
AndroidKeystoreEntity = 'AndroidKeystoreEntity',
|
||||
AppEntity = 'AppEntity',
|
||||
AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity',
|
||||
AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity',
|
||||
AppStoreConnectAppEntity = 'AppStoreConnectAppEntity',
|
||||
AppleDeviceEntity = 'AppleDeviceEntity',
|
||||
AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity',
|
||||
AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity',
|
||||
@@ -5880,6 +6101,7 @@ export type GitHubRepository = {
|
||||
githubRepositoryIdentifier: Scalars['Int']['output'];
|
||||
githubRepositoryUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
metadata: GitHubRepositoryMetadata;
|
||||
nodeIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
@@ -5903,8 +6125,8 @@ export type GitHubRepositoryMutation = {
|
||||
createAndConfigureRepository: BackgroundJobReceipt;
|
||||
/** Create a GitHub repository for an App */
|
||||
createGitHubRepository: GitHubRepository;
|
||||
/** Delete a GitHub repository by ID */
|
||||
deleteGitHubRepository: GitHubRepository;
|
||||
/** Delete a GitHub repository by ID in the background */
|
||||
scheduleGitHubRepositoryDeletion: BackgroundJobReceipt;
|
||||
};
|
||||
|
||||
|
||||
@@ -5923,7 +6145,7 @@ export type GitHubRepositoryMutationCreateGitHubRepositoryArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = {
|
||||
export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = {
|
||||
githubRepositoryId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
@@ -7091,6 +7313,14 @@ export type PublishUpdateGroupInput = {
|
||||
updateInfoGroup?: InputMaybe<UpdateInfoGroup>;
|
||||
};
|
||||
|
||||
export type RemoteAppStoreConnectApp = {
|
||||
__typename?: 'RemoteAppStoreConnectApp';
|
||||
appStoreIconUrl?: Maybe<Scalars['String']['output']>;
|
||||
ascAppIdentifier: Scalars['String']['output'];
|
||||
bundleIdentifier: Scalars['String']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum RequestMethod {
|
||||
Delete = 'DELETE',
|
||||
Get = 'GET',
|
||||
@@ -7270,6 +7500,8 @@ export type RootMutation = {
|
||||
app?: Maybe<AppMutation>;
|
||||
/** Mutations that modify an App Store Connect Api Key */
|
||||
appStoreConnectApiKey: AppStoreConnectApiKeyMutation;
|
||||
/** Mutations for App Store Connect apps. */
|
||||
appStoreConnectApp: AppStoreConnectAppMutation;
|
||||
/** Mutations that modify an AppVersion */
|
||||
appVersion: AppVersionMutation;
|
||||
/** Mutations that modify an Identifier for an iOS App */
|
||||
@@ -7292,6 +7524,7 @@ export type RootMutation = {
|
||||
build: BuildMutation;
|
||||
/** Mutations that create, update, and delete Build Annotations */
|
||||
buildAnnotation: BuildAnnotationMutation;
|
||||
convexTeamConnection: ConvexTeamConnectionMutation;
|
||||
customDomain: CustomDomainMutation;
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
@@ -7308,6 +7541,8 @@ export type RootMutation = {
|
||||
echoProject: EchoProjectMutation;
|
||||
/** Mutations for Echo repository management via the GitHub App */
|
||||
echoRepository: EchoRepositoryMutation;
|
||||
/** Mutations for Echo turns */
|
||||
echoTurn: EchoTurnMutation;
|
||||
/** Mutations for Echo versions */
|
||||
echoVersion: EchoVersionMutation;
|
||||
/** Mutations that create and delete EnvironmentSecrets */
|
||||
@@ -7354,6 +7589,7 @@ export type RootMutation = {
|
||||
sentryProject: SentryProjectMutation;
|
||||
/** Mutations that modify an EAS Submit submission */
|
||||
submission: SubmissionMutation;
|
||||
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
|
||||
update: UpdateMutation;
|
||||
updateBranch: UpdateBranchMutation;
|
||||
updateChannel: UpdateChannelMutation;
|
||||
@@ -7493,6 +7729,7 @@ export type RootQuery = {
|
||||
/** Top-level query object for querying Expo status page services. */
|
||||
statuspageService: StatuspageServiceQuery;
|
||||
submissions: SubmissionQuery;
|
||||
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactQuery;
|
||||
/** Top-level query object for querying Updates. */
|
||||
updates: UpdateQuery;
|
||||
/** fetch all updates in a group */
|
||||
@@ -7705,6 +7942,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
industry?: Maybe<Scalars['String']['output']>;
|
||||
isExpoAdmin: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -7817,6 +8055,8 @@ export type SentryInstallationMutation = {
|
||||
__typename?: 'SentryInstallationMutation';
|
||||
/** Confirm a pending Sentry installation */
|
||||
confirmPendingSentryInstallation: SentryInstallation;
|
||||
/** Force delete a Sentry installation from Expo's side */
|
||||
deleteSentryInstallation: DeleteSentryInstallationResult;
|
||||
/** Generate a Sentry token for an installation */
|
||||
generateSentryToken: GenerateSentryTokenResult;
|
||||
/** Link a Sentry installation to an Expo account */
|
||||
@@ -7829,6 +8069,11 @@ export type SentryInstallationMutationConfirmPendingSentryInstallationArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SentryInstallationMutationDeleteSentryInstallationArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SentryInstallationMutationGenerateSentryTokenArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -7867,6 +8112,13 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
|
||||
sentryProjectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SizeBreakdownCategory = {
|
||||
__typename?: 'SizeBreakdownCategory';
|
||||
assetCount: Scalars['Int']['output'];
|
||||
category: Scalars['String']['output'];
|
||||
totalBytes: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type Snack = Project & {
|
||||
__typename?: 'Snack';
|
||||
/** Description of the Snack */
|
||||
@@ -8226,6 +8478,34 @@ export type TimelineActivityFilterInput = {
|
||||
types?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
|
||||
};
|
||||
|
||||
export type TurtleBrownfieldArtifactMutation = {
|
||||
__typename?: 'TurtleBrownfieldArtifactMutation';
|
||||
createTurtleBrownfieldArtifact: CreateBrownfieldArtifactResult;
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactMutationCreateTurtleBrownfieldArtifactArgs = {
|
||||
input: CreateBrownfieldArtifactInput;
|
||||
};
|
||||
|
||||
export type TurtleBrownfieldArtifactQuery = {
|
||||
__typename?: 'TurtleBrownfieldArtifactQuery';
|
||||
byId: BrownfieldArtifact;
|
||||
latestForApp?: Maybe<BrownfieldArtifact>;
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactQueryByIdArgs = {
|
||||
turtleBrownfieldArtifactId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type TurtleBrownfieldArtifactQueryLatestForAppArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
bundleName: Scalars['String']['input'];
|
||||
platform: AppPlatform;
|
||||
};
|
||||
|
||||
export type UniqueUsersOverTimeData = {
|
||||
__typename?: 'UniqueUsersOverTimeData';
|
||||
data: LineChartData;
|
||||
@@ -8520,8 +8800,10 @@ export type UpdateDeploymentsConnection = {
|
||||
export type UpdateDiffReceipt = {
|
||||
__typename?: 'UpdateDiffReceipt';
|
||||
appId: Scalars['ID']['output'];
|
||||
baseUpdate?: Maybe<Update>;
|
||||
baseUpdateId: Scalars['ID']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
errorCode?: Maybe<Scalars['String']['output']>;
|
||||
errorMessage?: Maybe<Scalars['String']['output']>;
|
||||
fileSize?: Maybe<Scalars['Int']['output']>;
|
||||
@@ -8674,6 +8956,7 @@ export type UpdateInsights = {
|
||||
cumulativeAverageMetrics: CumulativeAverageMetrics;
|
||||
cumulativeMetrics: CumulativeMetrics;
|
||||
id: Scalars['ID']['output'];
|
||||
sizeBreakdownByCategory: Array<SizeBreakdownCategory>;
|
||||
totalUniqueUsers: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
@@ -8839,6 +9122,7 @@ export type UsageMetricTotal = {
|
||||
export enum UsageMetricType {
|
||||
Bandwidth = 'BANDWIDTH',
|
||||
Build = 'BUILD',
|
||||
Credit = 'CREDIT',
|
||||
Minute = 'MINUTE',
|
||||
Request = 'REQUEST',
|
||||
Update = 'UPDATE',
|
||||
@@ -8903,6 +9187,7 @@ export type User = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
isLegacy: Scalars['Boolean']['output'];
|
||||
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -9010,6 +9295,7 @@ export type UserActor = {
|
||||
/** @deprecated No longer supported */
|
||||
industry?: Maybe<Scalars['String']['output']>;
|
||||
isExpoAdmin: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
@@ -10587,6 +10873,10 @@ export type WorkflowRunTimeRangeInput = {
|
||||
};
|
||||
|
||||
export enum WorkflowRunTriggerEventType {
|
||||
AppStoreConnectAppVersionStateChanged = 'APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED',
|
||||
AppStoreConnectBetaFeedbackSubmitted = 'APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED',
|
||||
AppStoreConnectBuildUploadStateChanged = 'APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED',
|
||||
AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED',
|
||||
EasSubmit = 'EAS_SUBMIT',
|
||||
ExpoLaunch = 'EXPO_LAUNCH',
|
||||
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
|
||||
@@ -11791,6 +12081,33 @@ export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{
|
||||
|
||||
export type GoogleServiceAccountKeyByIdQuery = { __typename?: 'RootQuery', googleServiceAccountKey: { __typename?: 'GoogleServiceAccountKeyQuery', byId: { __typename?: 'GoogleServiceAccountKey', id: string, keyJson: string } } };
|
||||
|
||||
export type AppObserveTimeSeriesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveTimeSeriesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveTimeSeriesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', timeSeries: { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } } } } } };
|
||||
|
||||
export type AppObserveAppVersionsQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveReleasesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveAppVersionsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', appVersions: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }> } } } };
|
||||
|
||||
export type AppObserveEventsQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter?: InputMaybe<AppObserveEventsFilter>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveEventsOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -11971,6 +12288,12 @@ export type EnvironmentVariableWithSecretFragment = { __typename?: 'EnvironmentV
|
||||
|
||||
export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } };
|
||||
|
||||
export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } };
|
||||
|
||||
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string };
|
||||
|
||||
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
|
||||
|
||||
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
|
||||
|
||||
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveTimeSeriesInput,
|
||||
AppObserveTimeSeriesStatistics,
|
||||
PageInfo,
|
||||
} from '../generated';
|
||||
import { print } from 'graphql';
|
||||
import {
|
||||
AppObserveAppVersionFragmentNode,
|
||||
AppObserveTimeSeriesFragmentNode,
|
||||
AppObserveEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
|
||||
export type AppObserveTimeSeriesResult = {
|
||||
appVersionMarkers: AppObserveAppVersion[];
|
||||
eventCount: number;
|
||||
statistics: AppObserveTimeSeriesStatistics;
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
timeSeries: AppObserveTimeSeriesResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQueryVariables = {
|
||||
appId: string;
|
||||
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
|
||||
};
|
||||
|
||||
type AppObserveAppVersionsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
appVersions: AppObserveAppVersion[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveAppVersionsQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
type AppObserveEventsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
events: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveEvent;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveEventsQueryVariables = {
|
||||
appId: string;
|
||||
filter?: AppObserveEventsFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveEventsOrderBy;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
metricName,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
appId: string;
|
||||
metricName: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
): Promise<AppObserveTimeSeriesResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTimeSeries(
|
||||
$appId: String!
|
||||
$input: AppObserveTimeSeriesInput!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
timeSeries(input: $input) {
|
||||
...AppObserveTimeSeriesFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveAppVersionFragmentNode)}
|
||||
${print(AppObserveTimeSeriesFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { metricName, platform, startTime, endTime },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.timeSeries;
|
||||
},
|
||||
|
||||
async appVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
}: {
|
||||
appId: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
metricNames?: string[];
|
||||
}
|
||||
): Promise<AppObserveAppVersion[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveAppVersions(
|
||||
$appId: String!
|
||||
$input: AppObserveReleasesInput!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
appVersions(input: $input) {
|
||||
...AppObserveAppVersionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveAppVersionFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.appVersions;
|
||||
},
|
||||
|
||||
async eventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveEventsQueryVariables
|
||||
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveEvents(
|
||||
$appId: String!
|
||||
$filter: AppObserveEventsFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveEventsOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
events(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveEventFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveEventFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.events;
|
||||
return {
|
||||
events: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const AppObserveTimeSeriesFragmentNode = gql`
|
||||
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
|
||||
appVersionMarkers {
|
||||
...AppObserveAppVersionFragment
|
||||
}
|
||||
eventCount
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveEventFragmentNode = gql`
|
||||
fragment AppObserveEventFragment on AppObserveEvent {
|
||||
id
|
||||
metricName
|
||||
metricValue
|
||||
timestamp
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
deviceModel
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
countryCode
|
||||
sessionId
|
||||
easClientId
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveAppVersionFragmentNode = gql`
|
||||
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
|
||||
appVersion
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
buildNumbers {
|
||||
appBuildNumber
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
easBuilds {
|
||||
easBuildId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
}
|
||||
}
|
||||
updates {
|
||||
appUpdateId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
easBuilds {
|
||||
easBuildId
|
||||
firstSeenAt
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
}
|
||||
}
|
||||
metrics {
|
||||
metricName
|
||||
eventCount
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -211,42 +211,11 @@ export class AppleConfigWriter {
|
||||
Object.keys(screenshots).length > 0 ? screenshots : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video previews for a specific locale.
|
||||
*
|
||||
* Accepts either the legacy single-config form or the multi-preview array form
|
||||
* for each preview type. When an array contains exactly one entry, it is
|
||||
* unwrapped to the legacy single-object form for stable, minimal config diffs.
|
||||
*/
|
||||
/** Set video previews for a specific locale */
|
||||
public setPreviews(locale: string, previews: ApplePreviews): void {
|
||||
this.schema.info = this.schema.info ?? {};
|
||||
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
|
||||
|
||||
if (Object.keys(previews).length === 0) {
|
||||
this.schema.info[locale].previews = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized: ApplePreviews = {};
|
||||
for (const [previewType, value] of Object.entries(previews)) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (value.length === 1) {
|
||||
normalized[previewType as keyof ApplePreviews] = value[0];
|
||||
} else {
|
||||
normalized[previewType as keyof ApplePreviews] = value;
|
||||
}
|
||||
} else {
|
||||
normalized[previewType as keyof ApplePreviews] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.schema.info[locale].previews = Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
|
||||
}
|
||||
|
||||
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
|
||||
|
||||
@@ -354,17 +354,6 @@ describe(PreviewsTask, () => {
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
// Mock AppPreviewSet.infoAsync for the reorder step
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [newPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
@@ -386,214 +375,6 @@ describe(PreviewsTask, () => {
|
||||
waitForProcessing: true,
|
||||
})
|
||||
);
|
||||
// Single preview, current order matches: reorder should be skipped.
|
||||
expect(reorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads multiple previews when configured as an array', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: [
|
||||
'./previews/intro.mp4',
|
||||
{ path: './previews/features.mp4', previewFrameTimeCode: '00:02:00' },
|
||||
'./previews/outro.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const intro = new AppPreview(requestContext, 'NEW_PV_INTRO', {
|
||||
fileName: 'intro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const features = new AppPreview(requestContext, 'NEW_PV_FEATURES', {
|
||||
fileName: 'features.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:02:00',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const outro = new AppPreview(requestContext, 'NEW_PV_OUTRO', {
|
||||
fileName: 'outro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post(`/v1/${AppPreviewSet.type}`)
|
||||
.reply(201, {
|
||||
data: {
|
||||
id: 'NEW_PSET_1',
|
||||
type: AppPreviewSet.type,
|
||||
attributes: {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppPreview, 'uploadAsync')
|
||||
.mockResolvedValueOnce(intro)
|
||||
.mockResolvedValueOnce(features)
|
||||
.mockResolvedValueOnce(outro);
|
||||
|
||||
// After upload Apple returns the previews in upload order, which already matches config.
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [intro, features, outro],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
expect(uploadSpy).toHaveBeenCalledTimes(3);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
requestContext,
|
||||
expect.objectContaining({ filePath: '/test/project/previews/intro.mp4' })
|
||||
);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
requestContext,
|
||||
expect.objectContaining({
|
||||
filePath: '/test/project/previews/features.mp4',
|
||||
previewFrameTimeCode: '00:02:00',
|
||||
})
|
||||
);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
requestContext,
|
||||
expect.objectContaining({ filePath: '/test/project/previews/outro.mp4' })
|
||||
);
|
||||
// Order already matches config order, no reorder call expected.
|
||||
expect(reorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reorders previews when ASC order differs from config order', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: ['./previews/a.mp4', './previews/b.mp4'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
// Both previews already exist (matching filename + size), so no upload happens.
|
||||
const previewA = new AppPreview(requestContext, 'PV_A', {
|
||||
fileName: 'a.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const previewB = new AppPreview(requestContext, 'PV_B', {
|
||||
fileName: 'b.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
// ASC reports them in [b, a] order — config wants [a, b].
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [previewB, previewA],
|
||||
} as any)
|
||||
);
|
||||
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(previewA);
|
||||
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [previewB, previewA],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(AppPreview.uploadAsync).not.toHaveBeenCalled();
|
||||
expect(reorderMock).toHaveBeenCalledWith({ appPreviews: ['PV_A', 'PV_B'] });
|
||||
});
|
||||
|
||||
it('skips a preview type whose array is empty', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
jest.spyOn(AppPreview, 'uploadAsync');
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(AppPreview.uploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads new preview with object config including previewFrameTimeCode', async () => {
|
||||
@@ -639,16 +420,6 @@ describe(PreviewsTask, () => {
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [newPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
@@ -673,67 +444,4 @@ describe(PreviewsTask, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync (multi-preview)', () => {
|
||||
it('emits an array when there are multiple previews in a set', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const previews = [
|
||||
new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'intro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
videoUrl: 'https://example.com/intro.mp4',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any),
|
||||
new AppPreview(requestContext, 'PV_2', {
|
||||
fileName: 'features.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:01:00',
|
||||
videoUrl: 'https://example.com/features.mp4',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any),
|
||||
];
|
||||
|
||||
jest.spyOn(previews[0], 'getVideoUrl').mockReturnValue('https://example.com/intro.mp4');
|
||||
jest.spyOn(previews[1], 'getVideoUrl').mockReturnValue('https://example.com/features.mp4');
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: previews,
|
||||
} as any)
|
||||
);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
|
||||
});
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
previewSets: new Map([['en-US', previewTypeMap]]),
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).toBeCalledWith('en-US', {
|
||||
[PreviewType.IPHONE_67]: [
|
||||
'store/apple/preview/en-US/IPHONE_67/intro.mp4',
|
||||
{
|
||||
path: 'store/apple/preview/en-US/IPHONE_67/features.mp4',
|
||||
previewFrameTimeCode: '00:01:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,35 +22,19 @@ export type PreviewsData = {
|
||||
previewSets: PreviewSetsMap;
|
||||
};
|
||||
|
||||
/** Normalized preview entry used internally by the upload pipeline. */
|
||||
type NormalizedPreviewConfig = {
|
||||
/**
|
||||
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
|
||||
*/
|
||||
function normalizePreviewConfig(config: ApplePreviewConfig): {
|
||||
path: string;
|
||||
previewFrameTimeCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a single preview config into an object form with path and optional previewFrameTimeCode.
|
||||
*/
|
||||
function normalizePreviewConfig(config: ApplePreviewConfig): NormalizedPreviewConfig {
|
||||
} {
|
||||
if (typeof config === 'string') {
|
||||
return { path: config };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the value stored in `ApplePreviews[previewType]` (which can be a single
|
||||
* config OR an array of configs) into an array of normalized configs.
|
||||
*/
|
||||
function normalizePreviewConfigs(
|
||||
value: ApplePreviewConfig | ApplePreviewConfig[]
|
||||
): NormalizedPreviewConfig[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizePreviewConfig);
|
||||
}
|
||||
return [normalizePreviewConfig(value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task for managing App Store video previews.
|
||||
* Downloads existing previews and uploads new ones based on store configuration.
|
||||
@@ -102,44 +86,34 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download all previews in this set, preserving order. Mirrors how
|
||||
// screenshots are handled: when a preview is in a broken state
|
||||
// (AWAITING_UPLOAD with no rendered videoUrl) the download will fail,
|
||||
// but we still preserve the entry pointing at its expected local path
|
||||
// so users can either drop in a replacement file or remove the entry
|
||||
// to delete the broken ASC record.
|
||||
const entries: ApplePreviewConfig[] = [];
|
||||
for (let i = 0; i < previewModels.length; i++) {
|
||||
const preview = previewModels[i];
|
||||
const downloaded = await downloadPreviewAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
previewType,
|
||||
preview,
|
||||
i
|
||||
);
|
||||
// For now, we only handle the first preview per set (App Store allows up to 3)
|
||||
// We can extend this later to support multiple previews
|
||||
const preview = previewModels[0];
|
||||
const downloaded = await downloadPreviewAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
previewType,
|
||||
preview,
|
||||
0
|
||||
);
|
||||
|
||||
const fileName = preview.attributes.fileName || `${String(i + 1).padStart(2, '0')}.mp4`;
|
||||
const relativePath =
|
||||
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
|
||||
// When the download succeeds, write the real path. When it fails
|
||||
// (e.g. the preview is in a broken AWAITING_UPLOAD state with no
|
||||
// rendered videoUrl), preserve the entry in config so the user can
|
||||
// either drop in a replacement file or remove the entry to delete
|
||||
// the broken ASC record.
|
||||
const fileName = preview.attributes.fileName || 'preview.mp4';
|
||||
const relativePath =
|
||||
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
|
||||
|
||||
if (preview.attributes.previewFrameTimeCode) {
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
});
|
||||
} else {
|
||||
entries.push(relativePath);
|
||||
}
|
||||
if (preview.attributes.previewFrameTimeCode) {
|
||||
previews[previewType] = {
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
};
|
||||
} else {
|
||||
previews[previewType] = relativePath;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For backwards compatibility with existing single-preview configs,
|
||||
// emit the legacy single-object form when there's exactly one entry.
|
||||
previews[previewType] = entries.length === 1 ? entries[0] : entries;
|
||||
}
|
||||
|
||||
if (Object.keys(previews).length > 0) {
|
||||
@@ -172,13 +146,8 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [previewType, previewValue] of Object.entries(previews)) {
|
||||
if (!previewValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizePreviewConfigs(previewValue);
|
||||
if (normalized.length === 0) {
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -186,7 +155,7 @@ export class PreviewsTask extends AppleTask {
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalized,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
context.previewSets.get(localeCode)
|
||||
);
|
||||
}
|
||||
@@ -195,20 +164,23 @@ export class PreviewsTask extends AppleTask {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a preview set against the configured previews.
|
||||
*
|
||||
* Mirrors the screenshots task: keeps unchanged previews (matched by filename
|
||||
* + size + completion state), uploads any new/changed previews, deletes
|
||||
* removed entries, and finally reorders the set to match config order.
|
||||
* Sync a preview set - upload new preview, delete old one if changed.
|
||||
*/
|
||||
async function syncPreviewSetAsync(
|
||||
projectDir: string,
|
||||
localization: AppStoreVersionLocalization,
|
||||
previewType: PreviewType,
|
||||
previewConfigs: NormalizedPreviewConfig[],
|
||||
previewConfig: { path: string; previewFrameTimeCode?: string },
|
||||
existingSets: Map<PreviewType, AppPreviewSet> | undefined
|
||||
): Promise<void> {
|
||||
const locale = localization.attributes.locale;
|
||||
const absolutePath = path.resolve(projectDir, previewConfig.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create the preview set
|
||||
let previewSet = existingSets?.get(previewType);
|
||||
@@ -229,127 +201,62 @@ async function syncPreviewSetAsync(
|
||||
|
||||
const existingPreviews = previewSet.attributes.appPreviews || [];
|
||||
|
||||
// Build a map of existing previews by filename for comparison.
|
||||
const existingByFilename = new Map<string, AppPreview>();
|
||||
// Check if we need to update (different filename, size, or no existing preview)
|
||||
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
|
||||
const localSize = fs.statSync(absolutePath).size;
|
||||
|
||||
if (
|
||||
existingPreview &&
|
||||
existingPreview.isComplete() &&
|
||||
existingPreview.attributes.fileSize === localSize
|
||||
) {
|
||||
// Preview with same filename exists, check if we need to update preview frame time code
|
||||
if (
|
||||
previewConfig.previewFrameTimeCode &&
|
||||
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
|
||||
) {
|
||||
await logAsync(
|
||||
() =>
|
||||
existingPreview.updateAsync({
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing previews that don't match
|
||||
for (const preview of existingPreviews) {
|
||||
existingByFilename.set(preview.attributes.fileName, preview);
|
||||
}
|
||||
|
||||
// Track which previews to keep (by id), and which configs need uploading.
|
||||
const keptPreviewIds = new Set<string>();
|
||||
type UploadEntry = {
|
||||
absolutePath: string;
|
||||
fileName: string;
|
||||
previewFrameTimeCode?: string;
|
||||
};
|
||||
const toUpload: UploadEntry[] = [];
|
||||
|
||||
for (const previewConfig of previewConfigs) {
|
||||
const absolutePath = path.resolve(projectDir, previewConfig.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
const existing = existingByFilename.get(fileName);
|
||||
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.isComplete() &&
|
||||
(localSize === null || existing.attributes.fileSize === localSize)
|
||||
) {
|
||||
keptPreviewIds.add(existing.id);
|
||||
existingByFilename.delete(fileName);
|
||||
|
||||
// If only the previewFrameTimeCode changed, patch it in place.
|
||||
if (
|
||||
previewConfig.previewFrameTimeCode &&
|
||||
existing.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
|
||||
) {
|
||||
await logAsync(
|
||||
() =>
|
||||
existing.updateAsync({
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
toUpload.push({
|
||||
absolutePath,
|
||||
fileName,
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete previews that are no longer in config.
|
||||
for (const preview of existingByFilename.values()) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Upload new previews.
|
||||
for (const entry of toUpload) {
|
||||
if (!fs.existsSync(entry.absolutePath)) {
|
||||
Log.warn(chalk`{yellow Video preview not found: ${entry.absolutePath}}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPreview = await logAsync(
|
||||
() =>
|
||||
AppPreview.uploadAsync(localization.context, {
|
||||
id: previewSet!.id,
|
||||
filePath: entry.absolutePath,
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: entry.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading video preview ${chalk.bold(entry.fileName)} (${locale})...`,
|
||||
success: `Uploaded video preview ${chalk.bold(entry.fileName)} (${locale})`,
|
||||
failure: `Failed uploading video preview ${chalk.bold(entry.fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
|
||||
keptPreviewIds.add(newPreview.id);
|
||||
}
|
||||
|
||||
// Reorder previews to match config order (mirrors screenshots).
|
||||
if (keptPreviewIds.size > 0) {
|
||||
const refreshedSet = await AppPreviewSet.infoAsync(localization.context, {
|
||||
id: previewSet.id,
|
||||
});
|
||||
const refreshedPreviews = refreshedSet.attributes.appPreviews || [];
|
||||
const previewsByFilename = new Map<string, AppPreview>();
|
||||
for (const p of refreshedPreviews) {
|
||||
previewsByFilename.set(p.attributes.fileName, p);
|
||||
}
|
||||
|
||||
const orderedIds: string[] = [];
|
||||
for (const previewConfig of previewConfigs) {
|
||||
const fileName = path.basename(previewConfig.path);
|
||||
const preview = previewsByFilename.get(fileName);
|
||||
if (preview) {
|
||||
orderedIds.push(preview.id);
|
||||
}
|
||||
}
|
||||
|
||||
const currentIds = refreshedPreviews.map(p => p.id);
|
||||
if (
|
||||
orderedIds.length > 0 &&
|
||||
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
|
||||
) {
|
||||
await previewSet.reorderPreviewsAsync({ appPreviews: orderedIds });
|
||||
if (preview.attributes.fileName !== fileName) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upload new preview
|
||||
await logAsync(
|
||||
() =>
|
||||
AppPreview.uploadAsync(localization.context, {
|
||||
id: previewSet!.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,16 +36,11 @@ export type ApplePreviewConfig =
|
||||
|
||||
/**
|
||||
* Video previews organized by display type.
|
||||
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config or an array of preview configs.
|
||||
* Apple supports up to 3 previews per device type. A single object/string is still
|
||||
* accepted for backwards compatibility with existing store configs.
|
||||
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
|
||||
* @example { "IPHONE_67": "./previews/demo.mp4" }
|
||||
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
|
||||
* @example { "IPHONE_67": ["./previews/intro.mp4", "./previews/features.mp4"] }
|
||||
*/
|
||||
export type ApplePreviews = Partial<
|
||||
Record<ApplePreviewType, ApplePreviewConfig | ApplePreviewConfig[]>
|
||||
>;
|
||||
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
|
||||
|
||||
export interface AppleMetadata {
|
||||
version?: string;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('maps "slowest" to METRIC_VALUE DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "fastest" to METRIC_VALUE ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "newest" to TIMESTAMP DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "oldest" to TIMESTAMP ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchObserveEventsAsync, () => {
|
||||
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'app-123',
|
||||
filter: {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
},
|
||||
first: 10,
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes platform in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 5,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
platform: AppObservePlatform.Ios,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appVersion in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '1.2.0',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appVersion: '1.2.0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appUpdateId in filter when updateId is provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
updateId: 'update-abc-123',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appUpdateId: 'update-abc-123',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
|
||||
expect(calledFilter).not.toHaveProperty('platform');
|
||||
expect(calledFilter).not.toHaveProperty('appVersion');
|
||||
expect(calledFilter).not.toHaveProperty('appUpdateId');
|
||||
});
|
||||
|
||||
it('returns events and pageInfo from the query result', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].metricValue).toBe(1.23);
|
||||
expect(result.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
after: 'cursor-abc',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({ first: 10, after: 'cursor-abc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits after when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledVars = mockEventsAsync.mock.calls[0][1];
|
||||
expect(calledVars).not.toHaveProperty('after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function makeAppVersion(
|
||||
appVersion: string,
|
||||
metrics: Array<{
|
||||
metricName: string;
|
||||
eventCount: number;
|
||||
statistics: {
|
||||
min: number;
|
||||
max: number;
|
||||
median: number;
|
||||
average: number;
|
||||
p80: number;
|
||||
p90: number;
|
||||
p99: number;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
__typename: 'AppObserveAppVersion' as const,
|
||||
appVersion,
|
||||
eventCount: metrics.reduce((sum, m) => sum + m.eventCount, 0),
|
||||
uniqueUserCount: 50,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: metrics.map(m => ({
|
||||
__typename: 'AppObserveAppVersionMetric' as const,
|
||||
...m,
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
...m.statistics,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchObserveMetricsAsync', () => {
|
||||
const mockAppVersionsAsync = jest.mocked(ObserveQuery.appVersionsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppVersionsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('fetches app versions with metricNames and assembles metricsMap', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: {
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
},
|
||||
},
|
||||
{
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
eventCount: 80,
|
||||
statistics: {
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'project-123',
|
||||
platform: AppObservePlatform.Ios,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
metricNames: ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
});
|
||||
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
|
||||
const metricsForVersion = metricsMap.get(key)!;
|
||||
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
eventCount: 100,
|
||||
});
|
||||
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
eventCount: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('fans out across multiple platforms', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([]);
|
||||
|
||||
await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
const platforms = mockAppVersionsAsync.mock.calls.map(call => call[1].platform);
|
||||
expect(platforms).toContain(AppObservePlatform.Ios);
|
||||
expect(platforms).toContain(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockAppVersionsAsync.mockImplementation(async (_client, { platform }) => {
|
||||
if (platform === AppObservePlatform.Android) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return [
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
});
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
eventCount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty map when all queries fail', async () => {
|
||||
mockAppVersionsAsync.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('3.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 10,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.2,
|
||||
median: 0.15,
|
||||
average: 0.15,
|
||||
p80: 0.18,
|
||||
p90: 0.19,
|
||||
p99: 0.2,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
|
||||
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('accumulates totalEventCounts per metric per platform', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { totalEventCounts } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(totalEventCounts.get('expo.app_startup.tti:IOS')).toBe(150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
|
||||
|
||||
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appIdentifier: 'com.example.app',
|
||||
appName: 'ExampleApp',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
eventBatchId: 'batch-1',
|
||||
tags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
const withNextPage: PageInfo = {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-abc',
|
||||
};
|
||||
|
||||
describe(buildObserveEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.85,
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
deviceOsVersion: '14',
|
||||
deviceModel: 'Pixel 8',
|
||||
countryCode: 'PL',
|
||||
timestamp: '2025-01-14T08:15:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// Escape codes are included, because the header is bolded.
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mValue App Version Platform Device Country Timestamp [22m
|
||||
----- ----------- ---------- --------- ------- ----------------------
|
||||
1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
|
||||
0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns yellow warning for empty array', () => {
|
||||
const output = buildObserveEventsTable([], noNextPage);
|
||||
expect(output).toContain('No events found.');
|
||||
});
|
||||
|
||||
it('shows metric name in summary header when options are provided', () => {
|
||||
const events = [createMockEvent({ metricName: 'expo.app_startup.tti' })];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
daysBack: 30,
|
||||
});
|
||||
|
||||
expect(output).toContain('TTI events for the last 30 days');
|
||||
});
|
||||
|
||||
it('shows date range in summary header when start/end provided', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(output).toContain('Cold Launch events from Jan 1, 2025 to Feb 1, 2025');
|
||||
});
|
||||
|
||||
it('shows - for null countryCode', () => {
|
||||
const events = [createMockEvent({ countryCode: null })];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// The country column should contain a dash
|
||||
const lines = output.split('\n');
|
||||
const dataLine = lines[2]; // header, separator, first data row
|
||||
expect(dataLine).toContain('-');
|
||||
});
|
||||
|
||||
it('appends next page hint when hasNextPage is true', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, withNextPage);
|
||||
|
||||
expect(output).toContain('Next page: --after cursor-abc');
|
||||
});
|
||||
|
||||
it('does not append next page hint when hasNextPage is false', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
expect(output).not.toContain('Next page');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveEventsJson, () => {
|
||||
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, withNextPage);
|
||||
|
||||
expect(result).toEqual({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: 'cursor-abc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null optional fields', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
countryCode: null,
|
||||
sessionId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, noNextPage);
|
||||
|
||||
expect(result.events[0].countryCode).toBeNull();
|
||||
expect(result.events[0].sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty events array for empty input', () => {
|
||||
const result = buildObserveEventsJson([], noNextPage);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import {
|
||||
ObserveMetricsMap,
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
makeMetricsKey,
|
||||
resolveStatKey,
|
||||
type MetricValues,
|
||||
} from '../formatMetrics';
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
|
||||
|
||||
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
|
||||
return {
|
||||
min: 0.1,
|
||||
median: 0.3,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveMetricsTable, () => {
|
||||
it('formats metrics grouped by version with metric columns', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
iosKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
|
||||
metricsMap.set(
|
||||
androidKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
// The header is bolded, thus the escape characters in the snapshot
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- ----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
|
||||
[1mAndroid[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----------
|
||||
1.1.0 0.25s (120) 1.12s (100)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('shows - for versions with no matching metric data', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----
|
||||
2.0.0 - (-) - (-)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns message when no metrics data found', () => {
|
||||
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
expect(output).toMatchInlineSnapshot(`"[33mNo metrics data found.[39m"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveMetricsJson, () => {
|
||||
it('produces JSON with all stats per metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions).toHaveLength(1);
|
||||
expect(result.versions[0]).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
platform: AppPlatform.Ios,
|
||||
metrics: {
|
||||
'expo.app_startup.tti': {
|
||||
min: 0.1,
|
||||
median: 0.12,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('produces null values when no observe data matches for a metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics).toEqual({
|
||||
'expo.app_startup.tti': {
|
||||
min: null,
|
||||
median: null,
|
||||
max: null,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(makeMetricsKey, () => {
|
||||
it('creates a key from version and platform', () => {
|
||||
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveStatKey, () => {
|
||||
it('resolves canonical stat names', () => {
|
||||
expect(resolveStatKey('min')).toBe('min');
|
||||
expect(resolveStatKey('max')).toBe('max');
|
||||
expect(resolveStatKey('median')).toBe('median');
|
||||
expect(resolveStatKey('average')).toBe('average');
|
||||
expect(resolveStatKey('p80')).toBe('p80');
|
||||
expect(resolveStatKey('p90')).toBe('p90');
|
||||
expect(resolveStatKey('p99')).toBe('p99');
|
||||
expect(resolveStatKey('eventCount')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('resolves short aliases', () => {
|
||||
expect(resolveStatKey('med')).toBe('median');
|
||||
expect(resolveStatKey('avg')).toBe('average');
|
||||
expect(resolveStatKey('count')).toBe('eventCount');
|
||||
expect(resolveStatKey('event_count')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_TABLE', () => {
|
||||
it('defaults to median, eventCount', () => {
|
||||
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_JSON', () => {
|
||||
it('includes all stats', () => {
|
||||
expect(DEFAULT_STATS_JSON).toEqual([
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom stats parameter', () => {
|
||||
it('table renders only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p99', 'eventCount']
|
||||
);
|
||||
|
||||
expect(output).toContain('TTI');
|
||||
expect(output).toContain('0.90s (42)');
|
||||
expect(output).not.toContain('TTI Min');
|
||||
expect(output).not.toContain('TTI Med');
|
||||
expect(output).not.toContain('TTI Max');
|
||||
});
|
||||
|
||||
it("table formats eventCount as integer without 's' suffix", () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: 100,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
|
||||
|
||||
expect(output).toContain('100');
|
||||
expect(output).not.toContain('100s');
|
||||
expect(output).not.toContain('100.00s');
|
||||
});
|
||||
|
||||
it('JSON includes only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p90', 'eventCount']
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
p90: 0.4,
|
||||
eventCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON uses default stats when not specified', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
|
||||
});
|
||||
|
||||
it('resolves short alias "ttr" to full metric name', () => {
|
||||
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
|
||||
});
|
||||
|
||||
it('resolves short alias "cold_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "warm_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "bundle_load" to full metric name', () => {
|
||||
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
|
||||
});
|
||||
|
||||
it('passes through full metric names unchanged', () => {
|
||||
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
|
||||
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
|
||||
'expo.app_startup.cold_launch_time'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unknown alias', () => {
|
||||
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
|
||||
});
|
||||
|
||||
it('passes through dot-containing custom metric names', () => {
|
||||
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export enum EventsOrderPreset {
|
||||
Slowest = 'SLOWEST',
|
||||
Fastest = 'FASTEST',
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST',
|
||||
}
|
||||
|
||||
export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
|
||||
const preset = input.toUpperCase() as EventsOrderPreset;
|
||||
switch (preset) {
|
||||
case EventsOrderPreset.Slowest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Fastest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
case EventsOrderPreset.Newest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Oldest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchObserveEventsOptions {
|
||||
metricName: string;
|
||||
orderBy: AppObserveEventsOrderBy;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
}
|
||||
|
||||
interface FetchObserveEventsResult {
|
||||
events: AppObserveEvent[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export async function fetchObserveEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveEventsOptions
|
||||
): Promise<FetchObserveEventsResult> {
|
||||
const filter: AppObserveEventsFilter = {
|
||||
metricName: options.metricName,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.eventsAsync(graphqlClient, {
|
||||
appId,
|
||||
filter,
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: options.orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export async function fetchTotalEventCountAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricName: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<number> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
try {
|
||||
const versions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: appPlatformToObservePlatform[appPlatform],
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames: [metricName],
|
||||
});
|
||||
return versions.reduce((sum, v) => {
|
||||
const metric = v.metrics.find(m => m.metricName === metricName);
|
||||
return sum + (metric?.eventCount ?? 0);
|
||||
}, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const counts = await Promise.all(queries);
|
||||
return counts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
BuildNumbersMap,
|
||||
MetricValues,
|
||||
ObserveMetricsMap,
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new EasCommandError(
|
||||
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchObserveMetricsResult {
|
||||
metricsMap: ObserveMetricsMap;
|
||||
buildNumbersMap: BuildNumbersMap;
|
||||
updateIdsMap: UpdateIdsMap;
|
||||
totalEventCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
export async function fetchObserveMetricsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricNames: string[],
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<FetchObserveMetricsResult> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
});
|
||||
return { appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const buildNumbersMap: BuildNumbersMap = new Map();
|
||||
const updateIdsMap: UpdateIdsMap = new Map();
|
||||
const totalEventCounts = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
const { appPlatform, appVersions } = result;
|
||||
|
||||
for (const version of appVersions) {
|
||||
const key = makeMetricsKey(version.appVersion, appPlatform);
|
||||
if (!metricsMap.has(key)) {
|
||||
metricsMap.set(key, new Map());
|
||||
}
|
||||
if (!buildNumbersMap.has(key)) {
|
||||
buildNumbersMap.set(
|
||||
key,
|
||||
version.buildNumbers.map(bn => bn.appBuildNumber)
|
||||
);
|
||||
}
|
||||
if (!updateIdsMap.has(key)) {
|
||||
updateIdsMap.set(
|
||||
key,
|
||||
version.updates.map(u => u.appUpdateId)
|
||||
);
|
||||
}
|
||||
|
||||
for (const metric of version.metrics) {
|
||||
const values: MetricValues = {
|
||||
min: metric.statistics.min,
|
||||
max: metric.statistics.max,
|
||||
median: metric.statistics.median,
|
||||
average: metric.statistics.average,
|
||||
p80: metric.statistics.p80,
|
||||
p90: metric.statistics.p90,
|
||||
p99: metric.statistics.p99,
|
||||
eventCount: metric.eventCount,
|
||||
};
|
||||
metricsMap.get(key)!.set(metric.metricName, values);
|
||||
|
||||
const eventCountKey = `${metric.metricName}:${appPlatform}`;
|
||||
totalEventCounts.set(
|
||||
eventCountKey,
|
||||
(totalEventCounts.get(eventCountKey) ?? 0) + metric.eventCount
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export interface AppVersionsResult {
|
||||
platform: AppPlatform;
|
||||
appVersions: AppObserveAppVersion[];
|
||||
}
|
||||
|
||||
export async function fetchObserveVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<AppVersionsResult[]> {
|
||||
const queries = platforms.map(async (appPlatform): Promise<AppVersionsResult | null> => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
return { platform: appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch app versions for ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
return results.filter((r): r is AppVersionsResult => r !== null);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveEvent, PageInfo } from '../graphql/generated';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export interface ObserveEventJson {
|
||||
id: string;
|
||||
metricName: string;
|
||||
metricValue: number;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
sessionId: string | null;
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildEventsTableOptions {
|
||||
metricName: string;
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
totalEventCount?: number;
|
||||
}
|
||||
|
||||
export function buildObserveEventsTable(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo,
|
||||
options?: BuildEventsTableOptions
|
||||
): string {
|
||||
if (events.length === 0) {
|
||||
return chalk.yellow('No events found.');
|
||||
}
|
||||
|
||||
const hasUpdates = events.some(e => e.appUpdateId);
|
||||
|
||||
const headers = [
|
||||
'Value',
|
||||
'App Version',
|
||||
...(hasUpdates ? ['Update'] : []),
|
||||
'Platform',
|
||||
'Device',
|
||||
'Country',
|
||||
'Timestamp',
|
||||
];
|
||||
|
||||
const rows: string[][] = events.map(event => [
|
||||
`${event.metricValue.toFixed(2)}s`,
|
||||
`${event.appVersion} (${event.appBuildNumber})`,
|
||||
...(hasUpdates ? [event.appUpdateId ?? '-'] : []),
|
||||
`${event.deviceOs} ${event.deviceOsVersion}`,
|
||||
event.deviceModel,
|
||||
event.countryCode ?? '-',
|
||||
formatTimestamp(event.timestamp),
|
||||
]);
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options) {
|
||||
const metricDisplay = getMetricDisplayName(options.metricName);
|
||||
let timeDesc: string;
|
||||
if (options.daysBack) {
|
||||
timeDesc = `for the last ${options.daysBack} days`;
|
||||
} else if (options.startTime && options.endTime) {
|
||||
timeDesc = `from ${formatDate(options.startTime)} to ${formatDate(options.endTime)}`;
|
||||
} else {
|
||||
timeDesc = '';
|
||||
}
|
||||
const totalDesc =
|
||||
options.totalEventCount != null
|
||||
? ` — ${options.totalEventCount.toLocaleString()} total events`
|
||||
: '';
|
||||
lines.push(chalk.bold(`${metricDisplay} events ${timeDesc}${totalDesc}`.trim()), '');
|
||||
}
|
||||
|
||||
lines.push(chalk.bold(headerLine), separatorLine, ...dataLines);
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveEventsJson(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo
|
||||
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
metricName: event.metricName,
|
||||
metricValue: event.metricValue,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AppObserveAppBuildNumber,
|
||||
AppObserveAppEasBuild,
|
||||
AppObserveAppUpdate,
|
||||
} from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { AppVersionsResult } from './fetchVersions';
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface AppVersionJson {
|
||||
platform: string;
|
||||
appVersion: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
buildNumbers: AppBuildNumberJson[];
|
||||
updates: AppUpdateJson[];
|
||||
}
|
||||
|
||||
export interface AppBuildNumberJson {
|
||||
appBuildNumber: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppUpdateJson {
|
||||
appUpdateId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppEasBuildJson {
|
||||
easBuildId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
}
|
||||
|
||||
function mapEasBuilds(easBuilds: AppObserveAppEasBuild[]): AppEasBuildJson[] {
|
||||
return easBuilds.map(b => ({
|
||||
easBuildId: b.easBuildId,
|
||||
firstSeenAt: b.firstSeenAt,
|
||||
eventCount: b.eventCount,
|
||||
uniqueUserCount: b.uniqueUserCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapBuildNumbers(buildNumbers: AppObserveAppBuildNumber[]): AppBuildNumberJson[] {
|
||||
return buildNumbers.map(bn => ({
|
||||
appBuildNumber: bn.appBuildNumber,
|
||||
firstSeenAt: bn.firstSeenAt,
|
||||
eventCount: bn.eventCount,
|
||||
uniqueUserCount: bn.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(bn.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
function mapUpdates(updates: AppObserveAppUpdate[]): AppUpdateJson[] {
|
||||
return updates.map(u => ({
|
||||
appUpdateId: u.appUpdateId,
|
||||
firstSeenAt: u.firstSeenAt,
|
||||
eventCount: u.eventCount,
|
||||
uniqueUserCount: u.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(u.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildObserveVersionsJson(results: AppVersionsResult[]): AppVersionJson[] {
|
||||
const output: AppVersionJson[] = [];
|
||||
for (const { platform, appVersions } of results) {
|
||||
for (const v of appVersions) {
|
||||
output.push({
|
||||
platform: platform as string,
|
||||
appVersion: v.appVersion,
|
||||
firstSeenAt: v.firstSeenAt,
|
||||
eventCount: v.eventCount,
|
||||
uniqueUserCount: v.uniqueUserCount,
|
||||
buildNumbers: mapBuildNumbers(v.buildNumbers),
|
||||
updates: mapUpdates(v.updates),
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveVersionsTable(results: AppVersionsResult[]): string {
|
||||
const hasAnyVersions = results.some(r => r.appVersions.length > 0);
|
||||
|
||||
if (!hasAnyVersions) {
|
||||
return chalk.yellow('No app versions found.');
|
||||
}
|
||||
|
||||
const headers = ['App Version', 'First Seen', 'Events', 'Users', 'Builds', 'Updates'];
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const { platform, appVersions } of results) {
|
||||
if (appVersions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
sections.push('');
|
||||
}
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = appVersions.map(version => [
|
||||
version.appVersion,
|
||||
formatDate(version.firstSeenAt),
|
||||
String(version.eventCount),
|
||||
String(version.uniqueUserCount),
|
||||
String(version.buildNumbers.length),
|
||||
String(version.updates.length),
|
||||
]);
|
||||
|
||||
sections.push(renderTable(headers, rows));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
|
||||
export const METRIC_ALIASES: Record<string, string> = {
|
||||
tti: 'expo.app_startup.tti',
|
||||
ttr: 'expo.app_startup.ttr',
|
||||
cold_launch: 'expo.app_startup.cold_launch_time',
|
||||
warm_launch: 'expo.app_startup.warm_launch_time',
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
if (METRIC_ALIASES[input]) {
|
||||
return METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { validateDateFlag } from './fetchMetrics';
|
||||
|
||||
export const DEFAULT_DAYS_BACK = 60;
|
||||
|
||||
export function startAndEndTime({
|
||||
daysBack,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
daysBack?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}) {
|
||||
let startTime: string;
|
||||
let endTime: string;
|
||||
|
||||
if (daysBack) {
|
||||
endTime = new Date().toISOString();
|
||||
startTime = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
endTime = end ?? new Date().toISOString();
|
||||
startTime =
|
||||
start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
export function resolveTimeRange(flags: { days?: number; start?: string; end?: string }): {
|
||||
daysBack?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} {
|
||||
if (flags.start) {
|
||||
validateDateFlag(flags.start, '--start');
|
||||
}
|
||||
if (flags.end) {
|
||||
validateDateFlag(flags.end, '--end');
|
||||
}
|
||||
|
||||
const daysBack = flags.days ?? (flags.start ? undefined : DEFAULT_DAYS_BACK);
|
||||
const { startTime, endTime } = startAndEndTime({
|
||||
daysBack,
|
||||
start: flags.start,
|
||||
end: flags.end,
|
||||
});
|
||||
|
||||
return { daysBack, startTime, endTime };
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export async function* batchUploadAsync(
|
||||
(() => {
|
||||
onProgressUpdate(getProgressValue());
|
||||
});
|
||||
let firstError: unknown = null;
|
||||
try {
|
||||
let index = 0;
|
||||
while (index < payloads.length || queue.size > 0) {
|
||||
@@ -215,29 +216,38 @@ export async function* batchUploadAsync(
|
||||
progressTracker[currentIndex] = progress;
|
||||
sendProgressUpdate();
|
||||
});
|
||||
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).finally(
|
||||
() => {
|
||||
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).then(
|
||||
result => {
|
||||
queue.delete(uploadPromise);
|
||||
progressTracker[currentIndex] = 1;
|
||||
return result;
|
||||
},
|
||||
error => {
|
||||
queue.delete(uploadPromise);
|
||||
firstError ??= error;
|
||||
controller.abort();
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
queue.add(uploadPromise);
|
||||
yield { payload, progress: getProgressValue() };
|
||||
}
|
||||
if (firstError) {
|
||||
break;
|
||||
}
|
||||
yield {
|
||||
...(await Promise.race(queue)),
|
||||
progress: getProgressValue(),
|
||||
};
|
||||
}
|
||||
|
||||
if (queue.size > 0) {
|
||||
controller.abort();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
throw error;
|
||||
firstError ??= error;
|
||||
}
|
||||
}
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadProgressBar {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/build-tools": "18.6.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -188,7 +188,12 @@ const BuildFunctionCallSchema = Joi.object({
|
||||
name: Joi.string(),
|
||||
workingDirectory: Joi.string(),
|
||||
shell: Joi.string(),
|
||||
env: Joi.object().pattern(Joi.string(), Joi.string().allow('')),
|
||||
env: Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.alternatives()
|
||||
.try(Joi.number().strict(), Joi.string().allow(''))
|
||||
.custom(value => String(value))
|
||||
),
|
||||
if: Joi.string(),
|
||||
timeout_minutes: Joi.number().positive(),
|
||||
// Internal field for metrics collection. Not documented publicly.
|
||||
|
||||
@@ -258,7 +258,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('invalid env type', () => {
|
||||
const buildConfig = {
|
||||
@@ -278,7 +278,48 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].run.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('env number coerced to string', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
run: {
|
||||
command: 'echo 123',
|
||||
env: {
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
assert(isBuildStepCommandRun(config.build.steps[0]));
|
||||
expect(config.build.steps[0].run.env).toEqual({ HOMEBREW_NO_AUTO_UPDATE: '1' });
|
||||
});
|
||||
test('numeric-like string env value is not coerced', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
run: {
|
||||
command: 'echo 123',
|
||||
env: {
|
||||
PORT: '001',
|
||||
SCALE: '1e3',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
assert(isBuildStepCommandRun(config.build.steps[0]));
|
||||
expect(config.build.steps[0].run.env).toEqual({ PORT: '001', SCALE: '1e3' });
|
||||
});
|
||||
test('valid timeout_minutes', () => {
|
||||
const buildConfig = {
|
||||
@@ -471,6 +512,36 @@ describe(validateConfig, () => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).not.toThrowError();
|
||||
});
|
||||
test('call with env number coerced to string', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
say_hi: {
|
||||
env: {
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1,
|
||||
PORT: 3000,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
functions: {
|
||||
say_hi: {
|
||||
command: 'echo "Hi!"',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config = validateConfig(BuildConfigSchema, buildConfig);
|
||||
const step = config.build.steps[0] as BuildStepFunctionCall;
|
||||
expect(step['say_hi'].env).toEqual({
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
PORT: '3000',
|
||||
VERBOSE: 'true',
|
||||
});
|
||||
});
|
||||
test('call with if statement', () => {
|
||||
const buildConfig = {
|
||||
build: {
|
||||
@@ -518,7 +589,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('invalid env structure', () => {
|
||||
const buildConfig = {
|
||||
@@ -544,7 +615,7 @@ describe(validateConfig, () => {
|
||||
|
||||
expect(() => {
|
||||
validateConfig(BuildConfigSchema, buildConfig);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be a string/);
|
||||
}).toThrowError(/"build.steps\[0\].say_hi.env.ENV1" must be one of \[number, string\]/);
|
||||
});
|
||||
test('call with inputs boolean', () => {
|
||||
const buildConfig = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.5.0",
|
||||
"version": "18.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/build-tools": "18.6.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -2856,14 +2856,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.5.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.6.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2871,7 +2871,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -3111,7 +3111,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:18.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:18.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -3128,12 +3128,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:18.5.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:18.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -3481,11 +3481,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:18.5.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:18.6.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -3554,16 +3554,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.5.0"
|
||||
"@expo/build-tools": "npm:18.6.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -10331,7 +10331,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10944,8 +10944,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/build-tools": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
@@ -11002,8 +11002,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:18.5.0"
|
||||
"@expo/eas-json": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-json": "npm:18.6.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
@@ -11018,7 +11018,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.5.0"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:5.0.0"
|
||||
"@graphql-codegen/introspection": "npm:4.0.0"
|
||||
|
||||
Reference in new issue
Block a user