mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d4262aa7 | ||
|
|
b22d89e41f | ||
|
|
81fe0f237f | ||
|
|
b9ddc04cf7 | ||
|
|
d6a8a4f694 | ||
|
|
7f163e3e52 | ||
|
|
f821cb176c | ||
|
|
3a70a5d9b8 | ||
|
|
4d74298bb7 | ||
|
|
3155fc26e7 | ||
|
|
0fb6dfc334 | ||
|
|
dd84646768 | ||
|
|
c3fbf63a71 | ||
|
|
7c0d7b60c7 | ||
|
|
5a8e1b4ba3 | ||
|
|
12b648a603 | ||
|
|
8c170af829 | ||
|
|
ad4c5299e3 | ||
|
|
dee07ae0a2 | ||
|
|
4ad12340f2 | ||
|
|
c6d4faefe7 | ||
|
|
2f8712a0f3 | ||
|
|
a439721054 | ||
|
|
2e2dddb59a | ||
|
|
b7df3ff4f8 |
No files matched your search
@@ -8,8 +8,45 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [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.4.0",
|
||||
"version": "18.7.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.4.0",
|
||||
"version": "18.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -37,17 +37,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@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.4.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
|
||||
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
|
||||
'Info.plist application target'
|
||||
);
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
const ctx = {
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
logger: { info: jest.fn() },
|
||||
job: {},
|
||||
};
|
||||
await configureXcodeProject(ctx as any, options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials and versions for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
async function configureXcodeProject(
|
||||
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -137,6 +138,10 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -18,7 +18,9 @@ import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
@@ -47,6 +49,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createEasBuildBuildFunctionGroup } from '../build';
|
||||
|
||||
function createMockBuildToolsContext(
|
||||
overrides: Partial<{
|
||||
platform: Platform;
|
||||
simulator: boolean;
|
||||
buildCredentials: Record<string, unknown>;
|
||||
}> = {}
|
||||
): CustomBuildContext<BuildJob> {
|
||||
return {
|
||||
job: {
|
||||
platform: overrides.platform ?? Platform.ANDROID,
|
||||
simulator: overrides.simulator ?? false,
|
||||
secrets: overrides.buildCredentials
|
||||
? { buildCredentials: overrides.buildCredentials }
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
}
|
||||
|
||||
describe(createEasBuildBuildFunctionGroup, () => {
|
||||
describe('working_directory input', () => {
|
||||
it('does not set working directory on steps when not provided (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
for (const step of steps) {
|
||||
// Without working_directory, only installPods would have a relative dir (iOS only).
|
||||
// On Android, no step should have a relative working directory.
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS simulator)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
|
||||
});
|
||||
|
||||
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for generic jobs (no platform)', () => {
|
||||
const buildToolsContext = {
|
||||
job: { platform: undefined },
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -199,11 +250,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -215,24 +271,29 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -242,6 +303,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -249,6 +311,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -257,6 +320,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -264,18 +328,26 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -286,24 +358,29 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -313,6 +390,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -320,6 +398,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -328,6 +407,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -335,21 +415,33 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -359,8 +451,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { createReadAppConfigBuildFunction } from '../readAppConfig';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
|
||||
describe(createReadAppConfigBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('reads and logs app configuration', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({
|
||||
logger,
|
||||
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
|
||||
});
|
||||
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
|
||||
logger: stepLogger,
|
||||
sdkVersion: '54.0.0',
|
||||
})
|
||||
);
|
||||
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
|
||||
expect(stepLogger.info).toHaveBeenCalledWith(
|
||||
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
|
||||
);
|
||||
const firstOutput = buildStep.outputById.app_config.value;
|
||||
expect(firstOutput).toBeDefined();
|
||||
expect(JSON.parse(firstOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 'my-app-slug',
|
||||
version: '1.2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if reading app config fails', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('exports app fields as returned by app config', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const secondOutput = buildStep.outputById.app_config.value;
|
||||
expect(secondOutput).toBeDefined();
|
||||
expect(JSON.parse(secondOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAndLogPackageJson } from '../../../utils/project';
|
||||
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
|
||||
|
||||
jest.mock('../../../utils/project');
|
||||
|
||||
describe(createReadPackageJsonBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('logs package.json contents', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAndLogPackageJson).toHaveBeenCalledWith(
|
||||
stepLogger,
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
expect(buildStep.outputById.package_json.value).toBe(
|
||||
JSON.stringify({ name: 'app', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if reading package.json fails', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
|
||||
throw new Error('failed to read package.json');
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
expect(stepLogger.error).toHaveBeenCalledWith({
|
||||
err: expect.any(Error),
|
||||
});
|
||||
expect(buildStep.outputById.package_json.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
@@ -212,4 +211,64 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails early on Linux when emulator host support check fails', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
|
||||
|
||||
const step = createStep();
|
||||
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
|
||||
});
|
||||
|
||||
it('continues startup on Linux when emulator host support check succeeds', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips emulator host support check when opt out env var is enabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: undefined,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: '/android/sdk/root',
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'/android/sdk/root/emulator/emulator',
|
||||
['-accel-check'],
|
||||
{
|
||||
env: expect.any(Object),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
|
||||
export function createReadAppConfigBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_app_config',
|
||||
name: 'Read app config',
|
||||
__metricsId: 'eas/read_app_config',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'app_config',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, outputs }) => {
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
stepCtx.logger.info('Using app configuration:');
|
||||
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
outputs.app_config.set(JSON.stringify(appConfig));
|
||||
} catch {
|
||||
// readAppConfig logs underlying failures.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAndLogPackageJson } from '../../utils/project';
|
||||
|
||||
export function createReadPackageJsonBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_package_json',
|
||||
name: 'Read package.json',
|
||||
__metricsId: 'eas/read_package_json',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'package_json',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
try {
|
||||
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
|
||||
outputs.package_json.set(JSON.stringify(packageJson));
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import {
|
||||
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
@@ -253,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
|
||||
if (!isAndroidEmulatorHostSupported) {
|
||||
throw new UserError(
|
||||
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
|
||||
'Could not start the Android emulator on this runner.\n\n' +
|
||||
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
|
||||
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
|
||||
' runs_on: linux-medium-nested-virtualization\n' +
|
||||
' runs_on: linux-large-nested-virtualization\n\n' +
|
||||
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
|
||||
try {
|
||||
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
|
||||
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
|
||||
export async function configureCredentialsAsync(
|
||||
logger: bunyan,
|
||||
@@ -29,6 +30,9 @@ export async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -133,6 +134,10 @@ export default class ProvisioningProfile {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -2,18 +2,29 @@ import { ExpoConfig } from '@expo/config';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { PackageManager } from '../packageManager';
|
||||
import { readEasJsonContents, runExpoCliCommand } from '../project';
|
||||
import { PackageManager, findPackagerRootDir } from '../packageManager';
|
||||
import {
|
||||
isUsingModernYarnVersion,
|
||||
readAndLogPackageJson,
|
||||
readEasJsonContents,
|
||||
runExpoCliCommand,
|
||||
} from '../project';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
findPackagerRootDir: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe(runExpoCliCommand, () => {
|
||||
describe('Expo SDK >= 46', () => {
|
||||
@@ -75,6 +86,61 @@ describe(runExpoCliCommand, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isUsingModernYarnVersion, () => {
|
||||
const projectDir = '/project';
|
||||
const mockedFindPackagerRootDir = findPackagerRootDir as jest.MockedFunction<
|
||||
typeof findPackagerRootDir
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFindPackagerRootDir.mockReturnValue(projectDir);
|
||||
jest.spyOn(fs, 'createReadStream').mockImplementation(((
|
||||
filePath: string,
|
||||
options?: { start?: number; end?: number }
|
||||
) => {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const start = options?.start ?? 0;
|
||||
const end = options?.end != null ? options.end + 1 : content.length;
|
||||
return Readable.from(Buffer.from(content.slice(start, end)));
|
||||
}) as any);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in project directory', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in workspace root directory', async () => {
|
||||
const workspaceRoot = '/workspace-root';
|
||||
mockedFindPackagerRootDir.mockReturnValue(workspaceRoot);
|
||||
vol.fromJSON({ [path.join(workspaceRoot, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no .yarnrc.yml and no yarn.lock exist', async () => {
|
||||
vol.fromJSON({ [projectDir]: null });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when yarn.lock contains classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when yarn.lock does not contain classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '__metadata:\n version: 8\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists even with classic yarn.lock', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(projectDir, '.yarnrc.yml')]: '',
|
||||
[path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n',
|
||||
});
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readEasJsonContents, () => {
|
||||
it('returns the raw eas.json contents even when it is not valid JSON', async () => {
|
||||
const projectDir = '/project';
|
||||
@@ -87,3 +153,23 @@ describe(readEasJsonContents, () => {
|
||||
expect(readEasJsonContents(projectDir)).toBe(contents);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readAndLogPackageJson, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('reads package.json, logs it, and returns parsed contents', async () => {
|
||||
const projectDir = '/project';
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = { name: 'app', version: '1.0.0' };
|
||||
const logger = { info: jest.fn() };
|
||||
|
||||
await fs.mkdirp(projectDir);
|
||||
await fs.writeJson(packageJsonPath, packageJson);
|
||||
|
||||
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,43 @@ import path from 'path';
|
||||
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
|
||||
async function readFirstChars(filePath: string, chars: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = fs.createReadStream(filePath, {
|
||||
start: 0,
|
||||
end: chars - 1,
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('data', chunk => {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir,
|
||||
* or if the `yarn.lock` file is classic one, not a modern one
|
||||
*/
|
||||
export async function isUsingModernYarnVersion(projectDir: string): Promise<boolean> {
|
||||
const rootDir = findPackagerRootDir(projectDir);
|
||||
const yarnrcPath = path.join(projectDir, '.yarnrc.yml');
|
||||
const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml');
|
||||
return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath));
|
||||
if ((await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const yarnlockPath = path.join(rootDir, 'yarn.lock');
|
||||
if (!(await fs.pathExists(yarnlockPath))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The yarn.lock file is for Yarn Classic, not Modern, if it contains "# yarn lockfile v1"
|
||||
const startOfLockfile = await readFirstChars(yarnlockPath, 100);
|
||||
return !/yarn lockfile v1/i.test(startOfLockfile);
|
||||
}
|
||||
|
||||
export function runExpoCliCommand({
|
||||
@@ -48,6 +78,16 @@ export function readPackageJson(projectDir: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
export function readAndLogPackageJson(
|
||||
logger: { info: (message: string) => void },
|
||||
projectDir: string
|
||||
): any {
|
||||
logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(projectDir);
|
||||
logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
export function readEasJsonContents(projectDir: string): string {
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
if (!fs.pathExistsSync(easJsonPath)) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["jest", "node"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gql.tada/ts-plugin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.4.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.4.0"
|
||||
"@expo/steps": "18.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.4.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -46,12 +46,406 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
cf_bundle_version: '42',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
cf_bundle_version: '42',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context without build_upload', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: 'not-a-uuid',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app and account context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -226,6 +226,72 @@ const GitHubContextZ = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const AppStoreConnectContextZ = z.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
app_version: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'accepted',
|
||||
'developer_rejected',
|
||||
'in_review',
|
||||
'invalid_binary',
|
||||
'metadata_rejected',
|
||||
'pending_apple_release',
|
||||
'pending_developer_release',
|
||||
'prepare_for_submission',
|
||||
'processing_for_distribution',
|
||||
'ready_for_distribution',
|
||||
'ready_for_review',
|
||||
'rejected',
|
||||
'replaced_with_new_version',
|
||||
'waiting_for_export_compliance',
|
||||
'waiting_for_review',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
cf_bundle_version: z.string().optional(),
|
||||
build: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
external_beta: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'processing',
|
||||
'processing_exception',
|
||||
'missing_export_compliance',
|
||||
'ready_for_beta_testing',
|
||||
'in_beta_testing',
|
||||
'expired',
|
||||
'ready_for_beta_submission',
|
||||
'in_export_compliance_review',
|
||||
'waiting_for_beta_review',
|
||||
'in_beta_review',
|
||||
'beta_rejected',
|
||||
'beta_approved',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
beta_feedback: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
type: z.enum(['crash', 'screenshot']),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -259,13 +325,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
app_store_connect: z
|
||||
.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
})
|
||||
.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<
|
||||
|
||||
+94
-91
@@ -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.4.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +422,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +442,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +460,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -488,7 +488,7 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
@@ -496,12 +496,13 @@ FLAGS
|
||||
build profile will be selected by default.
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
|
||||
--skip-bundler Install and run the development build without starting the bundler server.
|
||||
|
||||
DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,7 +523,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,7 +561,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,7 +601,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,7 +631,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,7 +657,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,7 +709,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,7 +727,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,7 +745,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,7 +762,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,7 +783,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,7 +804,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,7 +826,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,7 +846,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,7 +868,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,7 +890,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,7 +925,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,7 +948,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,7 +969,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +986,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,7 +1004,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,7 +1033,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,7 +1058,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,7 +1079,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,7 +1100,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,7 +1137,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,7 +1157,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,7 +1178,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,7 +1199,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,7 +1213,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1227,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,7 +1259,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,7 +1285,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,7 +1307,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,7 +1335,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1363,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,7 +1386,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,7 +1408,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,7 +1442,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,7 +1486,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,7 +1517,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,7 +1626,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1633,16 +1634,17 @@ generate the local store configuration from the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:pull [-e <value>]
|
||||
$ eas metadata:pull [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
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.4.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1650,16 +1652,17 @@ sync the local store configuration to the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:push [-e <value>]
|
||||
$ eas metadata:push [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
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.4.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1711,7 +1714,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1725,7 +1728,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1748,7 +1751,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1772,7 +1775,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1790,7 +1793,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1823,7 +1826,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1866,7 +1869,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1887,7 +1890,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1908,7 +1911,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1933,7 +1936,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1960,7 +1963,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1998,7 +2001,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2026,7 +2029,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2056,7 +2059,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2077,7 +2080,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2097,7 +2100,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2118,7 +2121,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2140,7 +2143,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2160,7 +2163,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2179,7 +2182,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2202,7 +2205,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2219,7 +2222,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2340,7 +2343,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2360,7 +2363,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2384,7 +2387,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2424,7 +2427,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2446,7 +2449,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2474,7 +2477,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2494,7 +2497,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2516,5 +2519,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+2868
-545
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.4.0",
|
||||
"version": "18.7.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -43,15 +43,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.16",
|
||||
"@expo/apple-utils": "2.1.19",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-json": "18.4.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.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -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.4.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"
|
||||
},
|
||||
|
||||
@@ -515,6 +515,57 @@
|
||||
"lootBox": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
|
||||
},
|
||||
"advertising": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app display advertising?"
|
||||
},
|
||||
"ageAssurance": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app implement age assurance mechanisms?"
|
||||
},
|
||||
"ageRatingOverrideV2": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleAgeRatingOverrideV2"
|
||||
}
|
||||
],
|
||||
"description": "Override the age rating using the V2 rating system. Allows setting a more granular age rating than the auto-calculated value."
|
||||
},
|
||||
"developerAgeRatingInfoUrl": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
],
|
||||
"description": "URL to additional developer-provided information about the app's age rating."
|
||||
},
|
||||
"gunsOrOtherWeapons": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain depictions of guns or other weapons?"
|
||||
},
|
||||
"healthOrWellnessTopics": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain health or wellness topics?"
|
||||
},
|
||||
"messagingAndChat": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain messaging or chat functionality?"
|
||||
},
|
||||
"parentalControls": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app implement parental controls?"
|
||||
},
|
||||
"userGeneratedContent": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain or display user-generated content?"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -637,6 +688,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClip": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target.",
|
||||
"properties": {
|
||||
"defaultExperience": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipDefaultExperience",
|
||||
"description": "The default experience for this App Clip. There is exactly one per app."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipDefaultExperience": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Default experience for an App Clip - the configuration shown when invoked without a custom experience.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": ["OPEN", "VIEW", "PLAY"],
|
||||
"description": "Action button shown in the App Clip card."
|
||||
},
|
||||
"releaseWithAppStoreVersion": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to release this default experience alongside the next App Store version."
|
||||
},
|
||||
"reviewDetail": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipReviewDetail",
|
||||
"description": "App Store review information for the App Clip default experience."
|
||||
},
|
||||
"info": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipInfo",
|
||||
"description": "Localized subtitle and header image for the App Clip default experience."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipReviewDetail": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App Store review detail for an App Clip default experience.",
|
||||
"required": ["invocationUrls"],
|
||||
"properties": {
|
||||
"invocationUrls": {
|
||||
"type": "array",
|
||||
"description": "Invocation URLs that App Review will use to launch the App Clip during review.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipLocalizedInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Localized subtitle and header image for an App Clip default experience.",
|
||||
"properties": {
|
||||
"subtitle": {
|
||||
"type": "string",
|
||||
"description": "Subtitle shown in the App Clip card. Apple limits this to 43 characters.",
|
||||
"maxLength": 43
|
||||
},
|
||||
"headerImage": {
|
||||
"type": "string",
|
||||
"description": "Relative path (from project root) to the App Clip header image PNG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipInfo": {
|
||||
"type": "object",
|
||||
"description": "Localized App Clip information, keyed by locale.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ar-SA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Arabic" },
|
||||
"ca": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Catalan" },
|
||||
"zh-Hans": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Simplified)" },
|
||||
"zh-Hant": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Traditional)" },
|
||||
"hr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Croatian" },
|
||||
"cs": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Czech" },
|
||||
"da": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Danish" },
|
||||
"nl-NL": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Dutch" },
|
||||
"en-AU": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Australia)" },
|
||||
"en-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Canada)" },
|
||||
"en-GB": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.K.)" },
|
||||
"en-US": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.S.)" },
|
||||
"fi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Finnish" },
|
||||
"fr-FR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French" },
|
||||
"fr-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French (Canada)" },
|
||||
"de-DE": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "German" },
|
||||
"el": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Greek" },
|
||||
"he": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hebrew" },
|
||||
"hi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hindi" },
|
||||
"hu": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hungarian" },
|
||||
"id": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Indonesian" },
|
||||
"it": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Italian" },
|
||||
"ja": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Japanese" },
|
||||
"ko": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Korean" },
|
||||
"ms": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Malay" },
|
||||
"no": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Norwegian" },
|
||||
"pl": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Polish" },
|
||||
"pt-BR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Brazil)" },
|
||||
"pt-PT": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Portugal)" },
|
||||
"ro": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Romanian" },
|
||||
"ru": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Russian" },
|
||||
"sk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Slovak" },
|
||||
"es-MX": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Mexico)" },
|
||||
"es-ES": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Spain)" },
|
||||
"sv": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Swedish" },
|
||||
"th": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Thai" },
|
||||
"tr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Turkish" },
|
||||
"uk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Ukrainian" },
|
||||
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
|
||||
}
|
||||
},
|
||||
"AppleAgeRatingOverride": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
@@ -650,6 +813,16 @@
|
||||
"FIFTEEN_PLUS",
|
||||
"NINETEEN_PLUS"
|
||||
]
|
||||
},
|
||||
"AppleAgeRatingOverrideV2": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"NINE_PLUS",
|
||||
"THIRTEEN_PLUS",
|
||||
"SIXTEEN_PLUS",
|
||||
"EIGHTEEN_PLUS",
|
||||
"UNRATED"
|
||||
]
|
||||
}
|
||||
},
|
||||
"AppleConfig": {
|
||||
@@ -857,6 +1030,10 @@
|
||||
"review": {
|
||||
"$ref": "#/definitions/apple/AppleReview",
|
||||
"description": "Info for the App Store reviewer"
|
||||
},
|
||||
"appClip": {
|
||||
"$ref": "#/definitions/apple/AppleAppClip",
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
|
||||
export const getLimitFlagWithCustomValues = ({
|
||||
defaultTo,
|
||||
limit,
|
||||
description,
|
||||
}: {
|
||||
defaultTo: number;
|
||||
limit: number;
|
||||
description?: string;
|
||||
}): Interfaces.OptionFlag<number | undefined> =>
|
||||
Flags.integer({
|
||||
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
description:
|
||||
description ??
|
||||
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export default class BuildDev extends EasCommand {
|
||||
description: 'Skip build if no successful build with matching fingerprint is found.',
|
||||
default: false,
|
||||
}),
|
||||
'skip-bundler': Flags.boolean({
|
||||
description: 'Install and run the development build without starting the bundler server.',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -129,7 +133,9 @@ export default class BuildDev extends EasCommand {
|
||||
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
await downloadAndRunAsync(build);
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
Log.warn('Artifacts for this build expired.');
|
||||
@@ -182,7 +188,9 @@ export default class BuildDev extends EasCommand {
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
});
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
}
|
||||
|
||||
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
|
||||
|
||||
@@ -362,8 +362,22 @@ export default class WorkerDeploy extends EasCommand {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
await finalizeDeployAsync(deployResult);
|
||||
let uploadError: unknown;
|
||||
try {
|
||||
await uploadAssetsAsync(assetFiles, deployResult);
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
try {
|
||||
await finalizeDeployAsync(deployResult);
|
||||
} catch (finalizeError) {
|
||||
if (!uploadError) {
|
||||
throw finalizeError;
|
||||
}
|
||||
}
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
|
||||
if (flags.aliasName) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# CLAUDE.md - metadata commands
|
||||
|
||||
`metadata:push` validates `store.config.json` against `packages/eas-cli/schema/metadata-0.json`. The schema can drift from the apple config reader/writer, and when it does `metadata:pull` happily writes fields that `metadata:push` then rejects — and the failure looks like the user's fault.
|
||||
|
||||
**Whenever you touch `src/metadata/apple/config/{reader,writer}.ts`, update `metadata-0.json` in the same change.** For new advisory fields:
|
||||
|
||||
- Add the property under `definitions.apple.AppleAdvisory.properties`.
|
||||
- For enums from `@expo/apple-utils`, add a matching `Apple<Name>` enum definition with the exact string values and reference it via `$ref`. Wrap in `oneOf` with `{ "type": "null" }` if the writer ever persists `null`.
|
||||
- Don't add to `required` — that breaks existing user configs. Optional with a sensible default in the writer is the convention.
|
||||
|
||||
The drift is caught by `src/metadata/apple/config/__tests__/writer.test.ts` ("writer output for ... passes JSON schema validation"). If you change apple-utils types or fixtures, that test will tell you exactly what the schema is missing.
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
|
||||
import { ensureProjectConfiguredAsync } from '../../build/configure';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { downloadMetadataAsync } from '../../metadata/download';
|
||||
@@ -21,10 +22,7 @@ export default class MetadataPull extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
|
||||
|
||||
import { ensureProjectConfiguredAsync } from '../../build/configure';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { handleMetadataError } from '../../metadata/errors';
|
||||
@@ -19,10 +20,7 @@ export default class MetadataPush extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
|
||||
import { buildObserveEventsJson } from '../../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
|
||||
jest.mock('../../../observe/fetchEvents', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchEvents');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveEventsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveEventsJson: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveEvents, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvents {
|
||||
const command = new ObserveEvents(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'tti',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --limit to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--limit', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('passes --after cursor to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--after', 'cursor-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.after).toBe('cursor-xyz');
|
||||
});
|
||||
|
||||
it('does not pass after when --after flag is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options).not.toHaveProperty('after');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
});
|
||||
|
||||
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
|
||||
const command = createCommand(['tti', '--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('passes --app-version to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--app-version', '2.1.0']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --update-id to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['tti', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
|
||||
mockEvents,
|
||||
expect.objectContaining({ hasNextPage: false })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand(['tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['tti', '--sort', 'slowest']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when no metric is provided', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('resolves lowercase "slowest" to MetricValue DESC', () => {
|
||||
expect(resolveOrderBy('slowest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "fastest" to MetricValue ASC', () => {
|
||||
expect(resolveOrderBy('fastest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "newest" to Timestamp DESC', () => {
|
||||
expect(resolveOrderBy('newest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves lowercase "oldest" to Timestamp ASC', () => {
|
||||
expect(resolveOrderBy('oldest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
|
||||
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveMetrics from '../metrics';
|
||||
|
||||
jest.mock('../../../observe/fetchMetrics', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchMetrics');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveMetricsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatMetrics', () => ({
|
||||
...jest.requireActual('../../../observe/formatMetrics'),
|
||||
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
|
||||
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
|
||||
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveMetrics, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveMetricsAsync.mockResolvedValue({
|
||||
metricsMap: new Map(),
|
||||
buildNumbersMap: new Map(),
|
||||
updateIdsMap: new Map(),
|
||||
totalEventCounts: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveMetrics {
|
||||
const command = new ObserveMetrics(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches metrics with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
|
||||
await command.runAsync();
|
||||
|
||||
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
|
||||
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
|
||||
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['p90', 'eventCount'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates --stat flags that resolve to the same key', async () => {
|
||||
const command = createCommand(['--stat', 'median', '--stat', 'median']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('uses default stats when --stat is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median', 'eventCount'],
|
||||
expect.objectContaining({ daysBack: 60 })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
|
||||
const command = createCommand([
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
'--stat',
|
||||
'min',
|
||||
'--stat',
|
||||
'average',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['min', 'average'],
|
||||
expect.any(Map),
|
||||
expect.any(Map),
|
||||
expect.any(Map)
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateDateFlag, () => {
|
||||
it('throws on invalid --start date', () => {
|
||||
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
|
||||
'Invalid --start date: "not-a-date"'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid --end date', () => {
|
||||
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
|
||||
});
|
||||
|
||||
it('accepts valid ISO date in --start', () => {
|
||||
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
|
||||
import {
|
||||
buildObserveVersionsJson,
|
||||
buildObserveVersionsTable,
|
||||
} from '../../../observe/formatVersions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveVersions from '../versions';
|
||||
|
||||
jest.mock('../../../observe/fetchVersions');
|
||||
jest.mock('../../../observe/formatVersions', () => ({
|
||||
buildObserveVersionsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveVersionsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveVersionsAsync = jest.mocked(fetchObserveVersionsAsync);
|
||||
const mockBuildObserveVersionsTable = jest.mocked(buildObserveVersionsTable);
|
||||
const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveVersions, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveVersions {
|
||||
const command = new ObserveVersions(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches versions with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
|
||||
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days combined with --end', async () => {
|
||||
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes the context project ID to fetchObserveVersionsAsync by default', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('uses --project-id flag when provided', async () => {
|
||||
const command = createCommand(['--project-id', 'override-project-id']);
|
||||
await command.runAsync();
|
||||
|
||||
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
|
||||
expect(appId).toBe('override-project-id');
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
mockFetchObserveVersionsAsync.mockResolvedValue([
|
||||
{
|
||||
platform: AppPlatform.Ios,
|
||||
appVersions: [
|
||||
{
|
||||
appVersion: '1.0.0',
|
||||
firstSeenAt: '2025-06-01T00:00:00.000Z',
|
||||
eventCount: 100,
|
||||
uniqueUserCount: 50,
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveVersionsJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls buildObserveVersionsTable for non-json output', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveVersionsTable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EventsOrderPreset,
|
||||
fetchObserveEventsAsync,
|
||||
fetchTotalEventCountAsync,
|
||||
resolveOrderBy,
|
||||
} from '../../observe/fetchEvents';
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display individual app performance events ordered by metric value';
|
||||
|
||||
static override args = {
|
||||
metric: Args.string({
|
||||
description: 'Metric to query (e.g. tti, cold_launch)',
|
||||
required: false,
|
||||
options: Object.keys(METRIC_ALIASES),
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
sort: Flags.option({
|
||||
description: 'Sort order for events',
|
||||
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
|
||||
required: false,
|
||||
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_EVENTS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
let metricName: string;
|
||||
if (args.metric) {
|
||||
metricName = resolveMetricName(args.metric);
|
||||
} else if (flags['non-interactive']) {
|
||||
throw new EasCommandError(
|
||||
'A metric argument is required in non-interactive mode. Available metrics: ' +
|
||||
Object.keys(METRIC_ALIASES).join(', ')
|
||||
);
|
||||
} else {
|
||||
const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({
|
||||
title: `${displayName} (${fullName})`,
|
||||
value: fullName,
|
||||
}));
|
||||
metricName = await selectAsync('Select a metric', choices);
|
||||
}
|
||||
const orderBy = resolveOrderBy(flags.sort);
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platform = flags.platform
|
||||
? flags.platform === 'android'
|
||||
? AppObservePlatform.Android
|
||||
: AppObservePlatform.Ios
|
||||
: undefined;
|
||||
|
||||
const platforms: AppPlatform[] = platform
|
||||
? [platform === AppObservePlatform.Android ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
metricName,
|
||||
orderBy,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
}),
|
||||
fetchTotalEventCountAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricName,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
),
|
||||
]);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveEventsTable(events, pageInfo, {
|
||||
metricName,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
totalEventCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_METRICS = [
|
||||
'expo.app_startup.cold_launch_time',
|
||||
'expo.app_startup.warm_launch_time',
|
||||
'expo.app_startup.tti',
|
||||
'expo.app_startup.ttr',
|
||||
'expo.app_startup.bundle_load_time',
|
||||
];
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
export default class ObserveMetrics extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display app performance metrics grouped by app version';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
metric: Flags.option({
|
||||
description: 'Metric name to display (can be specified multiple times).',
|
||||
multiple: true,
|
||||
options: Object.keys(METRIC_ALIASES),
|
||||
})(),
|
||||
stat: Flags.option({
|
||||
description: 'Statistic to display per metric (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: DEFAULT_STATS_JSON,
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetrics);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const metricNames = flags.metric?.length
|
||||
? flags.metric.map(resolveMetricName)
|
||||
: DEFAULT_METRICS;
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
|
||||
await fetchObserveMetricsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
? Array.from(new Set(flags.stat.map(resolveStatKey)))
|
||||
: undefined;
|
||||
|
||||
if (flags.json) {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
metricNames,
|
||||
stats,
|
||||
totalEventCounts,
|
||||
buildNumbersMap,
|
||||
updateIdsMap
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveMetricsTable(metricsMap, metricNames, stats, {
|
||||
daysBack,
|
||||
buildNumbersMap,
|
||||
totalEventCounts,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveVersions extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'display app versions with build and update details';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
|
||||
let projectId: string;
|
||||
let graphqlClient;
|
||||
if (flags['project-id']) {
|
||||
projectId = flags['project-id'];
|
||||
const ctx = await this.getContextAsync(
|
||||
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
|
||||
{ nonInteractive: flags['non-interactive'] }
|
||||
);
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
} else {
|
||||
const ctx = await this.getContextAsync(ObserveVersions, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
projectId = ctx.projectId;
|
||||
graphqlClient = ctx.loggedIn.graphqlClient;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const { startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const results = await fetchObserveVersionsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveVersionsJson(results));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveVersionsTable(results));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import { getAppFromContextAsync } from './actions/BuildCredentialsUtils';
|
||||
import { SetUpBuildCredentials } from './actions/SetUpBuildCredentials';
|
||||
import { SetUpPushKey } from './actions/SetUpPushKey';
|
||||
import { App, IosCredentials, Target } from './types';
|
||||
import { isAdHocProfile, isEnterpriseUniversalProfile } from './utils/provisioningProfile';
|
||||
import {
|
||||
isAdHocProfile,
|
||||
isDevelopmentProfile,
|
||||
isEnterpriseUniversalProfile,
|
||||
} from './utils/provisioningProfile';
|
||||
import { CommonIosAppCredentialsFragment } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findApplicationTarget } from '../../project/ios/target';
|
||||
@@ -150,6 +154,7 @@ export default class IosCredentialsProvider {
|
||||
|
||||
private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void {
|
||||
const isAdHoc = isAdHocProfile(provisioningProfile);
|
||||
const isDevelopment = isDevelopmentProfile(provisioningProfile);
|
||||
const isEnterprise = isEnterpriseUniversalProfile(provisioningProfile);
|
||||
if (this.options.distribution === 'internal') {
|
||||
if (this.options.enterpriseProvisioning === 'universal' && !isEnterprise) {
|
||||
@@ -164,16 +169,21 @@ export default class IosCredentialsProvider {
|
||||
targetName ? ` (target '${targetName})'` : ''
|
||||
} for internal distribution if you specified "enterpriseProvisioning": "adhoc" in eas.json`
|
||||
);
|
||||
} else if (!this.options.enterpriseProvisioning && !isEnterprise && !isAdHoc) {
|
||||
} else if (
|
||||
!this.options.enterpriseProvisioning &&
|
||||
!isEnterprise &&
|
||||
!isAdHoc &&
|
||||
!isDevelopment
|
||||
) {
|
||||
throw new Error(
|
||||
`You must use an adhoc provisioning profile${
|
||||
`You must use an adhoc or development provisioning profile${
|
||||
targetName ? ` (target '${targetName})'` : ''
|
||||
} for internal distribution.`
|
||||
);
|
||||
}
|
||||
} else if (isAdHoc) {
|
||||
} else if (isAdHoc || isDevelopment) {
|
||||
throw new Error(
|
||||
`You can't use an adhoc provisioning profile${
|
||||
`You can't use ${isAdHoc ? 'an adhoc' : 'a development'} provisioning profile${
|
||||
targetName ? ` (target '${targetName}')` : ''
|
||||
} for app store distribution.`
|
||||
);
|
||||
|
||||
@@ -23,7 +23,21 @@ export function readProfileName(dataBase64: string): string {
|
||||
export function isAdHocProfile(dataBase64: string): boolean {
|
||||
const profilePlist = parse(dataBase64);
|
||||
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
|
||||
return Array.isArray(provisionedDevices);
|
||||
if (!Array.isArray(provisionedDevices)) {
|
||||
return false;
|
||||
}
|
||||
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
|
||||
return !entitlements?.['get-task-allow'];
|
||||
}
|
||||
|
||||
export function isDevelopmentProfile(dataBase64: string): boolean {
|
||||
const profilePlist = parse(dataBase64);
|
||||
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
|
||||
if (!Array.isArray(provisionedDevices)) {
|
||||
return false;
|
||||
}
|
||||
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
|
||||
return !!entitlements?.['get-task-allow'];
|
||||
}
|
||||
|
||||
export function isEnterpriseUniversalProfile(dataBase64: string): boolean {
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -34,6 +34,15 @@ export const emptyAdvisory: AgeRatingDeclarationProps = {
|
||||
ageRatingOverride: null,
|
||||
koreaAgeRatingOverride: null,
|
||||
lootBox: null,
|
||||
advertising: null,
|
||||
ageAssurance: null,
|
||||
ageRatingOverrideV2: null,
|
||||
developerAgeRatingInfoUrl: null,
|
||||
gunsOrOtherWeapons: null,
|
||||
healthOrWellnessTopics: null,
|
||||
messagingAndChat: null,
|
||||
parentalControls: null,
|
||||
userGeneratedContent: null,
|
||||
};
|
||||
|
||||
export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
|
||||
@@ -55,6 +64,15 @@ export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
|
||||
ageRatingOverride: RatingOverride.NONE,
|
||||
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
|
||||
lootBox: false,
|
||||
advertising: false,
|
||||
ageAssurance: false,
|
||||
ageRatingOverrideV2: null,
|
||||
developerAgeRatingInfoUrl: null,
|
||||
gunsOrOtherWeapons: Rating.NONE,
|
||||
healthOrWellnessTopics: false,
|
||||
messagingAndChat: false,
|
||||
parentalControls: false,
|
||||
userGeneratedContent: false,
|
||||
};
|
||||
|
||||
export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
|
||||
@@ -76,6 +94,15 @@ export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
|
||||
ageRatingOverride: RatingOverride.SEVENTEEN_PLUS,
|
||||
koreaAgeRatingOverride: KoreaRatingOverride.NINETEEN_PLUS,
|
||||
lootBox: true,
|
||||
advertising: true,
|
||||
ageAssurance: true,
|
||||
ageRatingOverrideV2: null,
|
||||
developerAgeRatingInfoUrl: null,
|
||||
gunsOrOtherWeapons: Rating.FREQUENT_OR_INTENSE,
|
||||
healthOrWellnessTopics: true,
|
||||
messagingAndChat: true,
|
||||
parentalControls: true,
|
||||
userGeneratedContent: true,
|
||||
};
|
||||
|
||||
export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
|
||||
@@ -97,4 +124,13 @@ export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
|
||||
ageRatingOverride: RatingOverride.NONE,
|
||||
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
|
||||
lootBox: false,
|
||||
advertising: false,
|
||||
ageAssurance: false,
|
||||
ageRatingOverrideV2: null,
|
||||
developerAgeRatingInfoUrl: null,
|
||||
gunsOrOtherWeapons: Rating.NONE,
|
||||
healthOrWellnessTopics: false,
|
||||
messagingAndChat: false,
|
||||
parentalControls: false,
|
||||
userGeneratedContent: false,
|
||||
};
|
||||
@@ -176,3 +176,43 @@ describe('getVersion', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppClip', () => {
|
||||
it('returns null when no app clip is configured', () => {
|
||||
const reader = new AppleConfigReader({});
|
||||
expect(reader.getAppClip()).toBeNull();
|
||||
expect(reader.getAppClipDefaultExperience()).toBeNull();
|
||||
expect(reader.getAppClipLocales()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the configured app clip block', () => {
|
||||
const reader = new AppleConfigReader({
|
||||
appClip: {
|
||||
defaultExperience: {
|
||||
action: 'OPEN',
|
||||
releaseWithAppStoreVersion: true,
|
||||
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
|
||||
info: {
|
||||
'en-US': { subtitle: 'Quick experience', headerImage: 'header.png' },
|
||||
'fr-FR': { subtitle: 'Expérience rapide' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(reader.getAppClipDefaultExperience()).toMatchObject({
|
||||
action: 'OPEN',
|
||||
releaseWithAppStoreVersion: true,
|
||||
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
|
||||
});
|
||||
expect(reader.getAppClipLocales().sort()).toEqual(['en-US', 'fr-FR']);
|
||||
expect(reader.getAppClipLocalizedInfo('en-US')).toEqual({
|
||||
subtitle: 'Quick experience',
|
||||
headerImage: 'header.png',
|
||||
});
|
||||
expect(reader.getAppClipLocalizedInfo('fr-FR')).toEqual({
|
||||
subtitle: 'Expérience rapide',
|
||||
});
|
||||
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { nameAndDemoReviewDetails, nameOnlyReviewDetails } from './fixtures/appS
|
||||
import { automaticRelease, manualRelease, scheduledRelease } from './fixtures/appStoreVersion';
|
||||
import { dutchVersion, englishVersion } from './fixtures/appStoreVersionLocalization';
|
||||
import { phasedRelease } from './fixtures/appStoreVersionPhasedRelease';
|
||||
import { validateConfig } from '../../../config/validate';
|
||||
import { AppleConfigWriter } from '../writer';
|
||||
|
||||
describe('toSchema', () => {
|
||||
@@ -42,6 +43,22 @@ describe('setAgeRating', () => {
|
||||
writer.setAgeRating(mostRestrictiveAdvisory);
|
||||
expect(writer.schema.advisory).toMatchObject(mostRestrictiveAdvisory);
|
||||
});
|
||||
|
||||
// Drift guard: every advisory field that the writer emits must be allowed by
|
||||
// metadata-0.json. If you add a field to writer.ts (or apple-utils surfaces a
|
||||
// new one upstream), update the schema in the same change. Without this
|
||||
// test, the drift only shows up at `metadata:push` time as a confusing
|
||||
// validation error against the user's freshly pulled config.
|
||||
it.each([
|
||||
['emptyAdvisory', emptyAdvisory],
|
||||
['leastRestrictiveAdvisory', leastRestrictiveAdvisory],
|
||||
['mostRestrictiveAdvisory', mostRestrictiveAdvisory],
|
||||
['kidsSixToEightAdvisory', kidsSixToEightAdvisory],
|
||||
])('writer output for %s passes JSON schema validation', (_name, advisory) => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAgeRating(advisory);
|
||||
expect(validateConfig(writer.toSchema())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setInfoLocale', () => {
|
||||
@@ -329,3 +346,72 @@ describe('setReviewDetails', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppClipDefaultExperience', () => {
|
||||
it('sets default experience attributes', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipDefaultExperience({
|
||||
action: 'OPEN',
|
||||
releaseWithAppStoreVersion: true,
|
||||
});
|
||||
expect(writer.schema.appClip?.defaultExperience).toMatchObject({
|
||||
action: 'OPEN',
|
||||
releaseWithAppStoreVersion: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppClipReviewDetail', () => {
|
||||
it('sets invocation urls', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
|
||||
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toEqual({
|
||||
invocationUrls: ['https://example.com/clip'],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes review detail when null', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
|
||||
writer.setAppClipReviewDetail(null);
|
||||
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes review detail when invocationUrls is empty', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
|
||||
writer.setAppClipReviewDetail({ invocationUrls: [] });
|
||||
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppClipLocalizedInfo', () => {
|
||||
it('writes localized subtitle and header image', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipLocalizedInfo('en-US', {
|
||||
subtitle: 'Quick experience',
|
||||
headerImage: 'store/apple/app-clip/en-US/header.png',
|
||||
});
|
||||
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toEqual({
|
||||
subtitle: 'Quick experience',
|
||||
headerImage: 'store/apple/app-clip/en-US/header.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits empty locale entries', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
|
||||
writer.setAppClipLocalizedInfo('en-US', {});
|
||||
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps multiple locales independent', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
|
||||
writer.setAppClipLocalizedInfo('fr-FR', { subtitle: 'Rapide' });
|
||||
expect(writer.schema.appClip?.defaultExperience?.info).toMatchObject({
|
||||
'en-US': { subtitle: 'Quick' },
|
||||
'fr-FR': { subtitle: 'Rapide' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
import uniq from '../../../utils/expodash/uniq';
|
||||
import { AttributesOf } from '../../utils/asc';
|
||||
import { removeDatePrecision } from '../../utils/date';
|
||||
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
|
||||
import {
|
||||
AppleAppClip,
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
|
||||
|
||||
@@ -57,6 +64,15 @@ export class AppleConfigReader {
|
||||
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
|
||||
violenceRealisticProlongedGraphicOrSadistic:
|
||||
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
|
||||
advertising: attributes.advertising ?? false,
|
||||
ageAssurance: attributes.ageAssurance ?? false,
|
||||
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
|
||||
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
|
||||
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
|
||||
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
|
||||
messagingAndChat: attributes.messagingAndChat ?? false,
|
||||
parentalControls: attributes.parentalControls ?? false,
|
||||
userGeneratedContent: attributes.userGeneratedContent ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,4 +237,24 @@ export class AppleConfigReader {
|
||||
public getPreviews(locale: string): ApplePreviews | null {
|
||||
return this.schema.info?.[locale]?.previews ?? null;
|
||||
}
|
||||
|
||||
/** Get the App Clip configuration block, or null if no App Clip is configured. */
|
||||
public getAppClip(): AppleAppClip | null {
|
||||
return this.schema.appClip ?? null;
|
||||
}
|
||||
|
||||
/** Get the App Clip default experience, or null if not configured. */
|
||||
public getAppClipDefaultExperience(): AppleAppClipDefaultExperience | null {
|
||||
return this.schema.appClip?.defaultExperience ?? null;
|
||||
}
|
||||
|
||||
/** Locales that have App Clip default-experience info configured. */
|
||||
public getAppClipLocales(): string[] {
|
||||
return uniq(Object.keys(this.schema.appClip?.defaultExperience?.info ?? {}));
|
||||
}
|
||||
|
||||
/** Get the localized App Clip info (subtitle + header image) for a specific locale. */
|
||||
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
|
||||
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
} from '@expo/apple-utils';
|
||||
|
||||
import { AttributesOf } from '../../utils/asc';
|
||||
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
|
||||
import {
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleAppClipReviewDetail,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Serializes the Apple ASC entities into the metadata configuration schema.
|
||||
@@ -55,6 +62,15 @@ export class AppleConfigWriter {
|
||||
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
|
||||
violenceRealisticProlongedGraphicOrSadistic:
|
||||
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
|
||||
advertising: attributes.advertising ?? false,
|
||||
ageAssurance: attributes.ageAssurance ?? false,
|
||||
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
|
||||
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
|
||||
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
|
||||
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
|
||||
messagingAndChat: attributes.messagingAndChat ?? false,
|
||||
parentalControls: attributes.parentalControls ?? false,
|
||||
userGeneratedContent: attributes.userGeneratedContent ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,6 +217,44 @@ export class AppleConfigWriter {
|
||||
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
|
||||
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
|
||||
}
|
||||
|
||||
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
|
||||
public setAppClipDefaultExperience(
|
||||
attributes: Pick<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
|
||||
): void {
|
||||
this.schema.appClip = this.schema.appClip ?? {};
|
||||
this.schema.appClip.defaultExperience = {
|
||||
...this.schema.appClip.defaultExperience,
|
||||
action: attributes.action,
|
||||
releaseWithAppStoreVersion: attributes.releaseWithAppStoreVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the App Clip App Store review detail (invocation URLs). */
|
||||
public setAppClipReviewDetail(reviewDetail: AppleAppClipReviewDetail | null): void {
|
||||
this.schema.appClip = this.schema.appClip ?? {};
|
||||
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
|
||||
if (reviewDetail && reviewDetail.invocationUrls.length > 0) {
|
||||
this.schema.appClip.defaultExperience.reviewDetail = reviewDetail;
|
||||
} else {
|
||||
delete this.schema.appClip.defaultExperience.reviewDetail;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set per-locale App Clip info (subtitle + header image). */
|
||||
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
|
||||
this.schema.appClip = this.schema.appClip ?? {};
|
||||
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
|
||||
this.schema.appClip.defaultExperience.info = this.schema.appClip.defaultExperience.info ?? {};
|
||||
if (info.subtitle || info.headerImage) {
|
||||
this.schema.appClip.defaultExperience.info[locale] = {
|
||||
subtitle: optional(info.subtitle ?? null),
|
||||
headerImage: optional(info.headerImage ?? null),
|
||||
};
|
||||
} else {
|
||||
delete this.schema.appClip.defaultExperience.info[locale];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { App } from '@expo/apple-utils';
|
||||
|
||||
import type { AgeRatingData } from './tasks/age-rating';
|
||||
import type { AppClipData } from './tasks/app-clip';
|
||||
import type { AppInfoData } from './tasks/app-info';
|
||||
import type { AppReviewData } from './tasks/app-review-detail';
|
||||
import type { AppVersionData } from './tasks/app-version';
|
||||
@@ -16,7 +17,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
|
||||
AgeRatingData &
|
||||
AppReviewData &
|
||||
ScreenshotsData &
|
||||
PreviewsData;
|
||||
PreviewsData &
|
||||
AppClipData;
|
||||
|
||||
/**
|
||||
* The unprepared partial apple data, used within the `prepareAsync` tasks.
|
||||
|
||||
@@ -5,4 +5,4 @@ import { getAdapter } from 'axios';
|
||||
// see: https://github.com/nock/nock#axios
|
||||
|
||||
export { default } from 'nock';
|
||||
getRequestClient().defaults.adapter = getAdapter('http');
|
||||
getRequestClient().defaults.adapter = getAdapter('http') as any;
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
App,
|
||||
AppClip,
|
||||
AppClipAppStoreReviewDetail,
|
||||
AppClipDefaultExperience,
|
||||
AppClipDefaultExperienceLocalization,
|
||||
AppStoreVersion,
|
||||
} from '@expo/apple-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
import { AppleConfigReader } from '../../config/reader';
|
||||
import { AppleConfigWriter } from '../../config/writer';
|
||||
import { AppleData, PartialAppleData } from '../../data';
|
||||
import { AppClipTask } from '../app-clip';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
function makeExperience(
|
||||
id: string,
|
||||
attrs: Partial<AppClipDefaultExperience['attributes']> = {}
|
||||
): AppClipDefaultExperience {
|
||||
return new AppClipDefaultExperience(requestContext, id, {
|
||||
action: null,
|
||||
...attrs,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function makeLocalization(
|
||||
id: string,
|
||||
locale: string,
|
||||
subtitle: string | null = null
|
||||
): AppClipDefaultExperienceLocalization {
|
||||
return new AppClipDefaultExperienceLocalization(requestContext, id, {
|
||||
locale,
|
||||
subtitle,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function makeVersion(id: string): AppStoreVersion {
|
||||
return new AppStoreVersion(requestContext, id, { versionString: '1.0.0' } as any);
|
||||
}
|
||||
|
||||
const APP_CLIPS_EMPTY_RESPONSE = {
|
||||
data: [],
|
||||
links: { self: 'https://api.appstoreconnect.apple.com/v1/apps/stub-id/appClips' },
|
||||
meta: { paging: { total: 0, limit: 50 } },
|
||||
};
|
||||
|
||||
describe(AppClipTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('initializes empty state when the app has no app clips', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(uri => uri.startsWith(`/v1/${App.type}/stub-id/${AppClip.type}`))
|
||||
.reply(200, APP_CLIPS_EMPTY_RESPONSE);
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppClipTask().prepareAsync({ context });
|
||||
|
||||
expect(context.appClip).toBeNull();
|
||||
expect(context.appClipDefaultExperience).toBeNull();
|
||||
expect(context.appClipLocalizations?.size).toBe(0);
|
||||
expect(context.appClipHeaderImages?.size).toBe(0);
|
||||
expect(context.appClipReviewDetail).toBeNull();
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('skips when no default experience is loaded', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new AppClipTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appClip: null,
|
||||
appClipDefaultExperience: null,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setAppClipDefaultExperience).not.toBeCalled();
|
||||
expect(writer.setAppClipLocalizedInfo).not.toBeCalled();
|
||||
expect(writer.setAppClipReviewDetail).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('writes from current version experience when present', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const experience = makeExperience('exp-current', { action: 'PLAY' as any });
|
||||
const localization = makeLocalization('loc-1', 'en-US', 'Quick play');
|
||||
|
||||
await new AppClipTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
appClipDefaultExperience: experience,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map([['en-US', localization]]),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
|
||||
expect.objectContaining({ action: 'PLAY' })
|
||||
);
|
||||
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
|
||||
'en-US',
|
||||
expect.objectContaining({ subtitle: 'Quick play' })
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to template experience when current version has none', async () => {
|
||||
// Regression test: when bumping the app version, the new version has no
|
||||
// default experience yet. Pull should still populate config from the
|
||||
// most recent prior experience (the template) so the user has data to
|
||||
// edit, instead of writing nothing.
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const template = makeExperience('exp-template', { action: 'OPEN' as any });
|
||||
const localization = makeLocalization('loc-1', 'en-US', 'Old subtitle');
|
||||
|
||||
await new AppClipTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
appClipDefaultExperience: null,
|
||||
appClipTemplateExperience: template,
|
||||
appClipLocalizations: new Map([['en-US', localization]]),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
|
||||
expect.objectContaining({ action: 'OPEN' })
|
||||
);
|
||||
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
|
||||
'en-US',
|
||||
expect.objectContaining({ subtitle: 'Old subtitle' })
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves expected local path when header image is not yet downloadable', async () => {
|
||||
// Regression test for the App Clip header image race condition: ASC
|
||||
// hasn't yet rendered the imageAsset (typically right after a fresh
|
||||
// upload), so the download URL is null. We should still preserve the
|
||||
// headerImage path in config so subsequent pushes don't try to delete
|
||||
// the in-progress upload.
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const experience = makeExperience('exp-1', { action: 'OPEN' as any });
|
||||
const localization = makeLocalization('loc-1', 'en-US', 'Sub');
|
||||
// Header image with no rendered imageAsset (still processing).
|
||||
const headerImage = {
|
||||
attributes: { fileName: 'header.png', imageAsset: null },
|
||||
getImageAssetUrl: () => null,
|
||||
} as any;
|
||||
|
||||
await new AppClipTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
appClipDefaultExperience: experience,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map([['en-US', localization]]),
|
||||
appClipHeaderImages: new Map([['en-US', headerImage]]),
|
||||
appClipReviewDetail: null,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
|
||||
'en-US',
|
||||
expect.objectContaining({
|
||||
subtitle: 'Sub',
|
||||
headerImage: 'store/apple/app-clip/en-US/header.png',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
it('skips when no app clip is configured in store config', async () => {
|
||||
const reader = new AppleConfigReader({});
|
||||
|
||||
await new AppClipTask().uploadAsync({
|
||||
config: reader,
|
||||
context: {
|
||||
appClip: null,
|
||||
appClipDefaultExperience: null,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
} as AppleData,
|
||||
});
|
||||
|
||||
// No HTTP calls should be made when nothing is configured.
|
||||
expect(nock.pendingMocks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns and aborts when ASC has no app clip but config does', async () => {
|
||||
const reader = new AppleConfigReader({
|
||||
appClip: {
|
||||
defaultExperience: { action: 'OPEN' },
|
||||
},
|
||||
});
|
||||
|
||||
await new AppClipTask().uploadAsync({
|
||||
config: reader,
|
||||
context: {
|
||||
appClip: null,
|
||||
appClipDefaultExperience: null,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
} as AppleData,
|
||||
});
|
||||
|
||||
// No HTTP calls when there's no clip in ASC to update.
|
||||
expect(nock.pendingMocks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns and aborts when no editable app store version is loaded', async () => {
|
||||
const reader = new AppleConfigReader({
|
||||
appClip: { defaultExperience: { action: 'OPEN' } },
|
||||
});
|
||||
|
||||
await new AppClipTask().uploadAsync({
|
||||
config: reader,
|
||||
context: {
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
version: null,
|
||||
appClipDefaultExperience: null,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(nock.pendingMocks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips PATCH when default experience action already matches config', async () => {
|
||||
// Regression test: Apple rejects PATCHes on `action` once the linked
|
||||
// version is locked (in review/released), so we must skip the call when
|
||||
// nothing changed to avoid spurious failures on round-trip pushes.
|
||||
const reader = new AppleConfigReader({
|
||||
appClip: { defaultExperience: { action: 'PLAY' } },
|
||||
});
|
||||
const existing = makeExperience('exp-current', { action: 'PLAY' as any });
|
||||
// Spy on updateAsync to assert it's not invoked.
|
||||
const updateSpy = jest.spyOn(existing, 'updateAsync');
|
||||
|
||||
await new AppClipTask().uploadAsync({
|
||||
config: reader,
|
||||
context: {
|
||||
app: new App(requestContext, 'app-1', {} as any),
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
version: makeVersion('ver-1'),
|
||||
appClipDefaultExperience: existing,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('skips review detail PATCH when invocation URLs already match', async () => {
|
||||
const reader = new AppleConfigReader({
|
||||
appClip: {
|
||||
defaultExperience: {
|
||||
action: 'OPEN',
|
||||
reviewDetail: { invocationUrls: ['https://a.example/', 'https://b.example/'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
const existing = makeExperience('exp-current', { action: 'OPEN' as any });
|
||||
const review = new AppClipAppStoreReviewDetail(requestContext, 'rev-1', {
|
||||
invocationUrls: ['https://a.example/', 'https://b.example/'],
|
||||
} as any);
|
||||
const updateSpy = jest.spyOn(review, 'updateAsync');
|
||||
|
||||
await new AppClipTask().uploadAsync({
|
||||
config: reader,
|
||||
context: {
|
||||
app: new App(requestContext, 'app-1', {} as any),
|
||||
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
|
||||
version: makeVersion('ver-1'),
|
||||
appClipDefaultExperience: existing,
|
||||
appClipTemplateExperience: null,
|
||||
appClipLocalizations: new Map(),
|
||||
appClipHeaderImages: new Map(),
|
||||
appClipReviewDetail: review,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,48 @@ describe(PreviewsTask, () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves entries with placeholder paths when videoUrl is null (broken state)', async () => {
|
||||
// Same regression as for screenshots: previews stuck in AWAITING_UPLOAD
|
||||
// with no rendered videoUrl used to be dropped from config. Now we
|
||||
// preserve the entry so the user can recover.
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const broken = new AppPreview(requestContext, 'PV_BROKEN', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 12345,
|
||||
videoUrl: null,
|
||||
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
|
||||
} as any);
|
||||
jest.spyOn(broken, 'getVideoUrl').mockReturnValue(null);
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [broken],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
previewSets: new Map([['en-US', previewTypeMap]]),
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(mockFetch).not.toBeCalled();
|
||||
expect(writer.setPreviews).toBeCalledWith('en-US', {
|
||||
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
@@ -401,5 +443,54 @@ describe(PreviewsTask, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes remote previews when preview type is removed from config', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
// No previews configured — the IPHONE_67 preview should be deleted
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const existingPreview = new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const deleteScope = nock('https://api.appstoreconnect.apple.com')
|
||||
.delete(`/v1/${AppPreview.type}/PV_1`)
|
||||
.reply(204);
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [existingPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(deleteScope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -170,6 +170,95 @@ describe(ScreenshotsTask, () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves entries with placeholder paths when imageAsset is null (broken state)', async () => {
|
||||
// Regression test for screenshots stuck in AWAITING_UPLOAD with no
|
||||
// rendered imageAsset. Pull used to drop these from config entirely,
|
||||
// which made it impossible to recover via push (since push only acts on
|
||||
// entries present in config). Now pull writes a placeholder path so the
|
||||
// user can drop in a replacement file or remove the entry to delete the
|
||||
// broken ASC record.
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
|
||||
fileName: '01.png',
|
||||
fileSize: 599307,
|
||||
imageAsset: null,
|
||||
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
|
||||
} as any);
|
||||
// getImageAssetUrl returns null when imageAsset is null.
|
||||
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
|
||||
|
||||
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
|
||||
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
|
||||
appScreenshots: [broken],
|
||||
} as any);
|
||||
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
|
||||
|
||||
const screenshotSets = new Map([['en-US', displayTypeMap]]);
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new ScreenshotsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
screenshotSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
// No fetch should have been attempted (URL was null).
|
||||
expect(mockFetch).not.toBeCalled();
|
||||
// Entry should still be present in config with the original filename.
|
||||
expect(writer.setScreenshots).toBeCalledWith('en-US', {
|
||||
[ScreenshotDisplayType.APP_IPHONE_67]: [
|
||||
'store/apple/screenshot/en-US/APP_IPHONE_67/01.png',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses index-based fallback filename when fileName is also null', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
|
||||
fileName: null,
|
||||
fileSize: 0,
|
||||
imageAsset: null,
|
||||
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
|
||||
} as any);
|
||||
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
|
||||
|
||||
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
|
||||
displayTypeMap.set(
|
||||
ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
|
||||
new AppScreenshotSet(requestContext, 'SET_2', {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
|
||||
appScreenshots: [broken],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new ScreenshotsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
screenshotSets: new Map([['en-US', displayTypeMap]]),
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setScreenshots).toBeCalledWith('en-US', {
|
||||
[ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129]: [
|
||||
'store/apple/screenshot/en-US/APP_IPAD_PRO_3GEN_129/01.png',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
AppClip,
|
||||
AppClipAppStoreReviewDetail,
|
||||
AppClipDefaultExperience,
|
||||
AppClipDefaultExperienceLocalization,
|
||||
AppClipHeaderImage,
|
||||
} from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import fetch from '../../../fetch';
|
||||
import Log from '../../../log';
|
||||
import { logAsync } from '../../utils/log';
|
||||
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
|
||||
|
||||
export type AppClipData = {
|
||||
/** The App Clip resource for the current app, if one exists. */
|
||||
appClip: AppClip | null;
|
||||
/**
|
||||
* The default experience attached to the *current editable* app store
|
||||
* version, if one exists. App Clip default experiences are versioned per
|
||||
* app store version, so this is null when the user has bumped the version
|
||||
* but not yet pushed an App Clip configuration for it.
|
||||
*/
|
||||
appClipDefaultExperience: AppClipDefaultExperience | null;
|
||||
/**
|
||||
* The most recent default experience from any other version, used as a
|
||||
* template when creating a new default experience for the current version
|
||||
* (and as the source for `metadata:pull` when the current version has none).
|
||||
*/
|
||||
appClipTemplateExperience: AppClipDefaultExperience | null;
|
||||
/** Existing localizations keyed by locale (e.g. `en-US`). */
|
||||
appClipLocalizations: Map<string, AppClipDefaultExperienceLocalization>;
|
||||
/** Existing header images keyed by locale. */
|
||||
appClipHeaderImages: Map<string, AppClipHeaderImage>;
|
||||
/** App Store review detail (invocation URLs), if one exists. */
|
||||
appClipReviewDetail: AppClipAppStoreReviewDetail | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Task for managing App Clip metadata (default experience, localized
|
||||
* subtitles + header images, App Store review invocation URLs).
|
||||
*
|
||||
* No-op when the app does not have an App Clip target.
|
||||
*/
|
||||
export class AppClipTask extends AppleTask {
|
||||
public name = (): string => 'app clip';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.appClip = null;
|
||||
context.appClipDefaultExperience = null;
|
||||
context.appClipTemplateExperience = null;
|
||||
context.appClipLocalizations = new Map();
|
||||
context.appClipHeaderImages = new Map();
|
||||
context.appClipReviewDetail = null;
|
||||
|
||||
const appClips = await context.app.getAppClipsAsync();
|
||||
if (appClips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apps may have multiple App Clip bundles registered. Pick the first
|
||||
// clip that has any default experiences attached.
|
||||
let chosenClip: AppClip | null = null;
|
||||
let allExperiences: AppClipDefaultExperience[] = [];
|
||||
for (const clip of appClips) {
|
||||
const experiences = await clip.getAppClipDefaultExperiencesAsync();
|
||||
if (experiences.length > 0) {
|
||||
chosenClip = clip;
|
||||
allExperiences = experiences;
|
||||
break;
|
||||
}
|
||||
}
|
||||
chosenClip ??= appClips[0];
|
||||
context.appClip = chosenClip;
|
||||
|
||||
if (allExperiences.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// App Clip default experiences are versioned per app store version. Find
|
||||
// the experience linked to the current editable version. Fall back to the
|
||||
// first experience as a template (used as the source of truth on pull
|
||||
// when the current version has no experience yet, and as a template when
|
||||
// creating a new experience on push).
|
||||
const currentVersionId = context.version?.id;
|
||||
const currentVersionExperience = currentVersionId
|
||||
? (allExperiences.find(
|
||||
exp => exp.attributes.releaseWithAppStoreVersion?.id === currentVersionId
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
context.appClipDefaultExperience = currentVersionExperience;
|
||||
// The template is only used when we need to CREATE a new default
|
||||
// experience for the current version on push — pick any other existing
|
||||
// experience (most recent first by sort order). When the current version
|
||||
// already has an experience, no template is needed.
|
||||
context.appClipTemplateExperience = currentVersionExperience
|
||||
? null
|
||||
: (allExperiences[0] ?? null);
|
||||
|
||||
// Use the current version's experience for downstream data when present;
|
||||
// otherwise fall back to the template so `metadata:pull` still produces a
|
||||
// populated `store.config.json`.
|
||||
const sourceExperience = currentVersionExperience ?? context.appClipTemplateExperience;
|
||||
if (!sourceExperience) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [localizations, reviewDetail] = await Promise.all([
|
||||
sourceExperience.getAppClipDefaultExperienceLocalizationsAsync(),
|
||||
sourceExperience.getAppClipAppStoreReviewDetailAsync(),
|
||||
]);
|
||||
|
||||
for (const localization of localizations) {
|
||||
context.appClipLocalizations.set(localization.attributes.locale, localization);
|
||||
}
|
||||
context.appClipReviewDetail = reviewDetail;
|
||||
|
||||
// Header images may be missing from the included payload depending on
|
||||
// ASC's whims; fetch any that weren't pre-populated.
|
||||
await Promise.all(
|
||||
localizations.map(async localization => {
|
||||
const included = localization.attributes.appClipHeaderImage;
|
||||
if (included) {
|
||||
context.appClipHeaderImages!.set(localization.attributes.locale, included);
|
||||
return;
|
||||
}
|
||||
const headerImage = await localization.getAppClipHeaderImageAsync();
|
||||
if (headerImage) {
|
||||
context.appClipHeaderImages!.set(localization.attributes.locale, headerImage);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
// Pull data from the current version's experience when present, otherwise
|
||||
// fall back to the template experience. The localizations and review
|
||||
// detail in `context` were populated from whichever was used.
|
||||
const experience = context.appClipDefaultExperience ?? context.appClipTemplateExperience;
|
||||
if (!experience) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.setAppClipDefaultExperience({
|
||||
action: experience.attributes.action ?? undefined,
|
||||
// ASC does not expose `releaseWithAppStoreVersion` directly on the
|
||||
// attributes — it's a relationship. We treat presence of an included
|
||||
// version as `true`. When unset we leave the field undefined so the
|
||||
// serializer omits it.
|
||||
releaseWithAppStoreVersion:
|
||||
experience.attributes.releaseWithAppStoreVersion != null ? true : undefined,
|
||||
});
|
||||
|
||||
const reviewDetail = context.appClipReviewDetail;
|
||||
if (reviewDetail?.attributes.invocationUrls?.length) {
|
||||
config.setAppClipReviewDetail({
|
||||
invocationUrls: reviewDetail.attributes.invocationUrls,
|
||||
});
|
||||
} else {
|
||||
config.setAppClipReviewDetail(null);
|
||||
}
|
||||
|
||||
for (const [locale, localization] of context.appClipLocalizations) {
|
||||
const headerImage = context.appClipHeaderImages.get(locale);
|
||||
let headerImagePath: string | undefined;
|
||||
if (headerImage) {
|
||||
const downloaded = await downloadAppClipHeaderImageAsync(
|
||||
context.projectDir,
|
||||
locale,
|
||||
headerImage
|
||||
);
|
||||
if (downloaded) {
|
||||
headerImagePath = downloaded;
|
||||
} else {
|
||||
// Image exists in ASC but isn't downloadable yet (still processing,
|
||||
// or Apple's CDN hasn't caught up). Preserve the expected local path
|
||||
// so subsequent pushes don't try to delete the in-progress upload.
|
||||
// Filename match in syncAppClipHeaderImageAsync will skip re-upload.
|
||||
const fileName = headerImage.attributes.fileName || 'header.png';
|
||||
headerImagePath = path.join('store', 'apple', 'app-clip', locale, fileName);
|
||||
}
|
||||
}
|
||||
config.setAppClipLocalizedInfo(locale, {
|
||||
subtitle: localization.attributes.subtitle ?? undefined,
|
||||
headerImage: headerImagePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
const desired = config.getAppClipDefaultExperience();
|
||||
if (!desired) {
|
||||
Log.log(chalk`{dim - Skipped app clip, not configured}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.appClip) {
|
||||
Log.warn(
|
||||
chalk`{yellow Skipping app clip - no App Clip is registered for this app in App Store Connect}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.version) {
|
||||
Log.warn(chalk`{yellow Skipping app clip - no editable app store version available}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// App Clip default experiences are versioned per app store version. If
|
||||
// the current editable version doesn't have one yet, create a new one,
|
||||
// optionally cloning the most recent prior experience as a template.
|
||||
let experience = context.appClipDefaultExperience;
|
||||
// We always link the default experience to the current editable version,
|
||||
// regardless of whether the user opted into `releaseWithAppStoreVersion`
|
||||
// — every default experience must belong to a version in ASC.
|
||||
const releaseWithAppStoreVersionId = context.version.id;
|
||||
|
||||
if (!experience) {
|
||||
const appClipId = context.appClip.id;
|
||||
const templateId = context.appClipTemplateExperience?.id;
|
||||
experience = await logAsync(
|
||||
() =>
|
||||
AppClipDefaultExperience.createAsync(context.app.context, {
|
||||
appClipId,
|
||||
releaseWithAppStoreVersionId,
|
||||
appClipDefaultExperienceTemplateId: templateId,
|
||||
attributes: { action: (desired.action as any) ?? null },
|
||||
}),
|
||||
{
|
||||
pending: templateId
|
||||
? `Creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)} (cloned from previous version)...`
|
||||
: `Creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}...`,
|
||||
success: `Created App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}`,
|
||||
failure: `Failed creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}`,
|
||||
}
|
||||
);
|
||||
context.appClipDefaultExperience = experience;
|
||||
|
||||
// Apple cloned the previous experience's localizations + review detail
|
||||
// into the new experience. Re-fetch them so subsequent diff/sync logic
|
||||
// operates on the new resource ids.
|
||||
const [localizations, reviewDetail] = await Promise.all([
|
||||
experience.getAppClipDefaultExperienceLocalizationsAsync(),
|
||||
experience.getAppClipAppStoreReviewDetailAsync(),
|
||||
]);
|
||||
context.appClipLocalizations = new Map();
|
||||
context.appClipHeaderImages = new Map();
|
||||
for (const localization of localizations) {
|
||||
context.appClipLocalizations.set(localization.attributes.locale, localization);
|
||||
const included = localization.attributes.appClipHeaderImage;
|
||||
if (included) {
|
||||
context.appClipHeaderImages.set(localization.attributes.locale, included);
|
||||
} else {
|
||||
const headerImage = await localization.getAppClipHeaderImageAsync();
|
||||
if (headerImage) {
|
||||
context.appClipHeaderImages.set(localization.attributes.locale, headerImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
context.appClipReviewDetail = reviewDetail;
|
||||
} else {
|
||||
const currentAction = experience.attributes.action ?? null;
|
||||
const desiredAction = (desired.action as any) ?? null;
|
||||
// Apple rejects PATCHes on `action` once the linked app store version is
|
||||
// locked (in review / released). Skip the call when nothing actually
|
||||
// changed to avoid spurious failures on round-trip pushes.
|
||||
if (currentAction !== desiredAction) {
|
||||
const existing = experience;
|
||||
experience = await logAsync(
|
||||
() =>
|
||||
existing.updateAsync({
|
||||
action: desiredAction,
|
||||
releaseWithAppStoreVersionId: desired.releaseWithAppStoreVersion
|
||||
? (releaseWithAppStoreVersionId ?? null)
|
||||
: null,
|
||||
}),
|
||||
{
|
||||
pending: 'Updating App Clip default experience...',
|
||||
success: 'Updated App Clip default experience',
|
||||
failure: 'Failed updating App Clip default experience',
|
||||
}
|
||||
);
|
||||
context.appClipDefaultExperience = experience;
|
||||
} else {
|
||||
Log.log(chalk`{dim - Skipped App Clip default experience, no changes}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync App Store review detail (invocation URLs).
|
||||
const desiredReview = desired.reviewDetail;
|
||||
if (desiredReview && desiredReview.invocationUrls.length > 0) {
|
||||
if (context.appClipReviewDetail) {
|
||||
const currentUrls = context.appClipReviewDetail.attributes.invocationUrls ?? [];
|
||||
const desiredUrls = desiredReview.invocationUrls;
|
||||
const unchanged =
|
||||
currentUrls.length === desiredUrls.length &&
|
||||
currentUrls.every((url, i) => url === desiredUrls[i]);
|
||||
if (!unchanged) {
|
||||
const existingReview = context.appClipReviewDetail;
|
||||
context.appClipReviewDetail = await logAsync(
|
||||
() =>
|
||||
existingReview.updateAsync({
|
||||
invocationUrls: desiredUrls,
|
||||
}),
|
||||
{
|
||||
pending: 'Updating App Clip review invocation URLs...',
|
||||
success: 'Updated App Clip review invocation URLs',
|
||||
failure: 'Failed updating App Clip review invocation URLs',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
context.appClipReviewDetail = await logAsync(
|
||||
() =>
|
||||
AppClipAppStoreReviewDetail.createAsync(context.app.context, {
|
||||
appClipDefaultExperienceId: experience.id,
|
||||
attributes: { invocationUrls: desiredReview.invocationUrls },
|
||||
}),
|
||||
{
|
||||
pending: 'Creating App Clip review invocation URLs...',
|
||||
success: 'Created App Clip review invocation URLs',
|
||||
failure: 'Failed creating App Clip review invocation URLs',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (context.appClipReviewDetail) {
|
||||
await logAsync(() => context.appClipReviewDetail!.deleteAsync(), {
|
||||
pending: 'Removing App Clip review invocation URLs...',
|
||||
success: 'Removed App Clip review invocation URLs',
|
||||
failure: 'Failed removing App Clip review invocation URLs',
|
||||
});
|
||||
context.appClipReviewDetail = null;
|
||||
}
|
||||
|
||||
// Sync localizations: create/update from config, delete locales that
|
||||
// exist in ASC but were removed from the config.
|
||||
const desiredLocales = config.getAppClipLocales();
|
||||
const desiredLocaleSet = new Set(desiredLocales);
|
||||
|
||||
for (const locale of desiredLocales) {
|
||||
const desiredInfo = config.getAppClipLocalizedInfo(locale);
|
||||
if (!desiredInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let localization = context.appClipLocalizations.get(locale);
|
||||
if (!localization) {
|
||||
localization = await logAsync(
|
||||
() =>
|
||||
experience.createAppClipDefaultExperienceLocalizationAsync({
|
||||
locale,
|
||||
subtitle: desiredInfo.subtitle ?? null,
|
||||
}),
|
||||
{
|
||||
pending: `Creating App Clip localization for ${chalk.bold(locale)}...`,
|
||||
success: `Created App Clip localization for ${chalk.bold(locale)}`,
|
||||
failure: `Failed creating App Clip localization for ${chalk.bold(locale)}`,
|
||||
}
|
||||
);
|
||||
context.appClipLocalizations.set(locale, localization);
|
||||
} else if (localization.attributes.subtitle !== (desiredInfo.subtitle ?? null)) {
|
||||
localization = await logAsync(
|
||||
() => localization!.updateAsync({ subtitle: desiredInfo.subtitle ?? null }),
|
||||
{
|
||||
pending: `Updating App Clip localization for ${chalk.bold(locale)}...`,
|
||||
success: `Updated App Clip localization for ${chalk.bold(locale)}`,
|
||||
failure: `Failed updating App Clip localization for ${chalk.bold(locale)}`,
|
||||
}
|
||||
);
|
||||
context.appClipLocalizations.set(locale, localization);
|
||||
}
|
||||
|
||||
// Sync header image.
|
||||
await syncAppClipHeaderImageAsync({
|
||||
projectDir: context.projectDir,
|
||||
localization,
|
||||
existing: context.appClipHeaderImages.get(locale),
|
||||
desiredPath: desiredInfo.headerImage,
|
||||
onUploaded: image => context.appClipHeaderImages.set(locale, image),
|
||||
onDeleted: () => context.appClipHeaderImages.delete(locale),
|
||||
});
|
||||
}
|
||||
|
||||
// Delete localizations no longer in config.
|
||||
for (const [locale, localization] of context.appClipLocalizations) {
|
||||
if (!desiredLocaleSet.has(locale)) {
|
||||
await logAsync(() => localization.deleteAsync(), {
|
||||
pending: `Deleting App Clip localization for ${chalk.bold(locale)}...`,
|
||||
success: `Deleted App Clip localization for ${chalk.bold(locale)}`,
|
||||
failure: `Failed deleting App Clip localization for ${chalk.bold(locale)}`,
|
||||
});
|
||||
context.appClipLocalizations.delete(locale);
|
||||
context.appClipHeaderImages.delete(locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload, replace, or delete the header image for a single App Clip
|
||||
* localization based on the desired config path.
|
||||
*/
|
||||
async function syncAppClipHeaderImageAsync({
|
||||
projectDir,
|
||||
localization,
|
||||
existing,
|
||||
desiredPath,
|
||||
onUploaded,
|
||||
onDeleted,
|
||||
}: {
|
||||
projectDir: string;
|
||||
localization: AppClipDefaultExperienceLocalization;
|
||||
existing: AppClipHeaderImage | undefined;
|
||||
desiredPath: string | undefined;
|
||||
onUploaded: (image: AppClipHeaderImage) => void;
|
||||
onDeleted: () => void;
|
||||
}): Promise<void> {
|
||||
const locale = localization.attributes.locale;
|
||||
|
||||
if (!desiredPath) {
|
||||
if (existing) {
|
||||
await logAsync(() => existing.deleteAsync(), {
|
||||
pending: `Deleting App Clip header image for ${chalk.bold(locale)}...`,
|
||||
success: `Deleted App Clip header image for ${chalk.bold(locale)}`,
|
||||
failure: `Failed deleting App Clip header image for ${chalk.bold(locale)}`,
|
||||
});
|
||||
onDeleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(projectDir, desiredPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow App Clip header image not found: ${absolutePath}}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
// Skip upload if the existing image already has the same filename and is
|
||||
// either fully processed or still being processed (i.e. anything that
|
||||
// isn't AWAITING_UPLOAD or FAILED). Apple does not expose the original
|
||||
// source bytes (only re-rendered copies), so we can't reliably compare
|
||||
// file size or checksum after a round-trip pull — filename is the only
|
||||
// stable identity available.
|
||||
if (
|
||||
existing &&
|
||||
!existing.isAwaitingUpload() &&
|
||||
!existing.isFailed() &&
|
||||
existing.attributes.fileName === fileName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await logAsync(() => existing.deleteAsync(), {
|
||||
pending: `Replacing App Clip header image for ${chalk.bold(locale)}...`,
|
||||
success: `Removed previous App Clip header image for ${chalk.bold(locale)}`,
|
||||
failure: `Failed removing previous App Clip header image for ${chalk.bold(locale)}`,
|
||||
});
|
||||
onDeleted();
|
||||
}
|
||||
|
||||
const uploaded = await logAsync(
|
||||
() =>
|
||||
AppClipHeaderImage.uploadAsync(localization.context, {
|
||||
id: localization.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading App Clip header image ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Uploaded App Clip header image ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed uploading App Clip header image ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
onUploaded(uploaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an App Clip header image to the local filesystem.
|
||||
* Returns the relative path to the downloaded file, or null on failure.
|
||||
*/
|
||||
async function downloadAppClipHeaderImageAsync(
|
||||
projectDir: string,
|
||||
locale: string,
|
||||
headerImage: AppClipHeaderImage
|
||||
): Promise<string | null> {
|
||||
const imageUrl = headerImage.getImageAssetUrl({ type: 'png' });
|
||||
if (!imageUrl) {
|
||||
Log.warn(chalk`{yellow Could not get download URL for App Clip header image (${locale})}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetDir = path.join(projectDir, 'store', 'apple', 'app-clip', locale);
|
||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const fileName = headerImage.attributes.fileName || 'header.png';
|
||||
const outputPath = path.join(targetDir, fileName);
|
||||
const relativePath = path.relative(projectDir, outputPath);
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.buffer();
|
||||
await fs.promises.writeFile(outputPath, buffer);
|
||||
|
||||
Log.log(chalk`{dim Downloaded App Clip header image: ${relativePath}}`);
|
||||
return relativePath;
|
||||
} catch (error: any) {
|
||||
Log.warn(
|
||||
chalk`{yellow Failed to download App Clip header image (${locale}): ${error.message}}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AgeRatingTask } from './age-rating';
|
||||
import { AppClipTask } from './app-clip';
|
||||
import { AppInfoTask } from './app-info';
|
||||
import { AppReviewDetailTask } from './app-review-detail';
|
||||
import { AppVersionOptions, AppVersionTask } from './app-version';
|
||||
@@ -21,5 +22,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
|
||||
new AppReviewDetailTask(),
|
||||
new ScreenshotsTask(),
|
||||
new PreviewsTask(),
|
||||
new AppClipTask(),
|
||||
];
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export class PreviewsTask extends AppleTask {
|
||||
// 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 relativePath = await downloadPreviewAsync(
|
||||
const downloaded = await downloadPreviewAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
previewType,
|
||||
@@ -97,16 +97,22 @@ export class PreviewsTask extends AppleTask {
|
||||
0
|
||||
);
|
||||
|
||||
if (relativePath) {
|
||||
// Include preview frame time code if available
|
||||
if (preview.attributes.previewFrameTimeCode) {
|
||||
previews[previewType] = {
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
};
|
||||
} else {
|
||||
previews[previewType] = relativePath;
|
||||
}
|
||||
// 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) {
|
||||
previews[previewType] = {
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
};
|
||||
} else {
|
||||
previews[previewType] = relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +136,7 @@ export class PreviewsTask extends AppleTask {
|
||||
|
||||
for (const localeCode of locales) {
|
||||
const previews = config.getPreviews(localeCode);
|
||||
if (!previews || Object.keys(previews).length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existingSets = context.previewSets.get(localeCode);
|
||||
|
||||
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
|
||||
if (!localization) {
|
||||
@@ -140,18 +144,39 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
// Upload/sync configured previews
|
||||
if (previews) {
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
context.previewSets.get(localeCode)
|
||||
);
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
existingSets
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete remote previews that are no longer in config
|
||||
if (existingSets) {
|
||||
for (const [previewType, previewSet] of existingSets) {
|
||||
if (previews?.[previewType]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPreviews = previewSet.attributes.appPreviews || [];
|
||||
for (const preview of existingPreviews) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})...`,
|
||||
success: `Deleted video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
|
||||
failure: `Failed deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,20 +73,32 @@ export class ScreenshotsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download screenshots and save to local filesystem
|
||||
// Download screenshots and save to local filesystem. When a screenshot
|
||||
// is in a broken state (AWAITING_UPLOAD with no rendered imageAsset)
|
||||
// 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 paths: string[] = [];
|
||||
for (let i = 0; i < screenshotModels.length; i++) {
|
||||
const screenshot = screenshotModels[i];
|
||||
const relativePath = await downloadScreenshotAsync(
|
||||
const downloaded = await downloadScreenshotAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
displayType,
|
||||
screenshot,
|
||||
i
|
||||
);
|
||||
if (relativePath) {
|
||||
paths.push(relativePath);
|
||||
if (downloaded) {
|
||||
paths.push(downloaded);
|
||||
continue;
|
||||
}
|
||||
// Fall back to a placeholder path so the entry isn't lost from
|
||||
// config. Push will detect that the existing screenshot isn't
|
||||
// complete and either re-upload (if a local file exists at this
|
||||
// path) or warn and skip (if it doesn't).
|
||||
const fileName =
|
||||
screenshot.attributes.fileName || `${String(i + 1).padStart(2, '0')}.png`;
|
||||
paths.push(path.join('store', 'apple', 'screenshot', localeCode, displayType, fileName));
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgeRatingDeclarationProps,
|
||||
AppClipAction,
|
||||
PreviewType,
|
||||
ScreenshotDisplayType,
|
||||
} from '@expo/apple-utils';
|
||||
@@ -51,6 +52,38 @@ export interface AppleMetadata {
|
||||
/** @deprecated Use screenshots/previews in AppleInfo instead */
|
||||
preview?: Record<string, string[]>;
|
||||
review?: AppleReview;
|
||||
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
|
||||
appClip?: AppleAppClip;
|
||||
}
|
||||
|
||||
/** App Clip action enum values from App Store Connect API */
|
||||
export type AppleAppClipAction = `${AppClipAction}`;
|
||||
|
||||
export interface AppleAppClip {
|
||||
/** The default experience for this App Clip. There is exactly one per app. */
|
||||
defaultExperience?: AppleAppClipDefaultExperience;
|
||||
}
|
||||
|
||||
export interface AppleAppClipDefaultExperience {
|
||||
/** Action button shown in the App Clip card. Defaults to OPEN if unset. */
|
||||
action?: AppleAppClipAction;
|
||||
/** Whether to release this default experience alongside the next App Store version. */
|
||||
releaseWithAppStoreVersion?: boolean;
|
||||
/** App Store review invocation URLs (used by App Review to launch the clip). */
|
||||
reviewDetail?: AppleAppClipReviewDetail;
|
||||
/** Per-locale subtitle and header image. */
|
||||
info?: Record<AppleLocale, AppleAppClipLocalizedInfo>;
|
||||
}
|
||||
|
||||
export interface AppleAppClipReviewDetail {
|
||||
invocationUrls: string[];
|
||||
}
|
||||
|
||||
export interface AppleAppClipLocalizedInfo {
|
||||
/** Subtitle shown in the App Clip card. Apple limits this to 43 characters. */
|
||||
subtitle?: string;
|
||||
/** Relative path (from project root) to the App Clip header image PNG. */
|
||||
headerImage?: string;
|
||||
}
|
||||
|
||||
// The omited properties are deprecated
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('maps "slowest" to METRIC_VALUE DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "fastest" to METRIC_VALUE ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "newest" to TIMESTAMP DESC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "oldest" to TIMESTAMP ASC', () => {
|
||||
expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchObserveEventsAsync, () => {
|
||||
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'app-123',
|
||||
filter: {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
},
|
||||
first: 10,
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes platform in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 5,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
platform: AppObservePlatform.Ios,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appVersion in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '1.2.0',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appVersion: '1.2.0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appUpdateId in filter when updateId is provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
updateId: 'update-abc-123',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appUpdateId: 'update-abc-123',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
|
||||
expect(calledFilter).not.toHaveProperty('platform');
|
||||
expect(calledFilter).not.toHaveProperty('appVersion');
|
||||
expect(calledFilter).not.toHaveProperty('appUpdateId');
|
||||
});
|
||||
|
||||
it('returns events and pageInfo from the query result', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-1',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].metricValue).toBe(1.23);
|
||||
expect(result.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
after: 'cursor-abc',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({ first: 10, after: 'cursor-abc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits after when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledVars = mockEventsAsync.mock.calls[0][1];
|
||||
expect(calledVars).not.toHaveProperty('after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function makeAppVersion(
|
||||
appVersion: string,
|
||||
metrics: Array<{
|
||||
metricName: string;
|
||||
eventCount: number;
|
||||
statistics: {
|
||||
min: number;
|
||||
max: number;
|
||||
median: number;
|
||||
average: number;
|
||||
p80: number;
|
||||
p90: number;
|
||||
p99: number;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
__typename: 'AppObserveAppVersion' as const,
|
||||
appVersion,
|
||||
eventCount: metrics.reduce((sum, m) => sum + m.eventCount, 0),
|
||||
uniqueUserCount: 50,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: metrics.map(m => ({
|
||||
__typename: 'AppObserveAppVersionMetric' as const,
|
||||
...m,
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
...m.statistics,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchObserveMetricsAsync', () => {
|
||||
const mockAppVersionsAsync = jest.mocked(ObserveQuery.appVersionsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppVersionsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('fetches app versions with metricNames and assembles metricsMap', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: {
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
},
|
||||
},
|
||||
{
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
eventCount: 80,
|
||||
statistics: {
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'project-123',
|
||||
platform: AppObservePlatform.Ios,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
metricNames: ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
});
|
||||
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
|
||||
const metricsForVersion = metricsMap.get(key)!;
|
||||
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
eventCount: 100,
|
||||
});
|
||||
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
eventCount: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('fans out across multiple platforms', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([]);
|
||||
|
||||
await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(mockAppVersionsAsync).toHaveBeenCalledTimes(2);
|
||||
|
||||
const platforms = mockAppVersionsAsync.mock.calls.map(call => call[1].platform);
|
||||
expect(platforms).toContain(AppObservePlatform.Ios);
|
||||
expect(platforms).toContain(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockAppVersionsAsync.mockImplementation(async (_client, { platform }) => {
|
||||
if (platform === AppObservePlatform.Android) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return [
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
});
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
eventCount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty map when all queries fail', async () => {
|
||||
mockAppVersionsAsync.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('3.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 10,
|
||||
statistics: {
|
||||
min: 0.1,
|
||||
max: 0.2,
|
||||
median: 0.15,
|
||||
average: 0.15,
|
||||
p80: 0.18,
|
||||
p90: 0.19,
|
||||
p99: 0.2,
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { metricsMap } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
|
||||
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('accumulates totalEventCounts per metric per platform', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('1.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 100,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
makeAppVersion('2.0.0', [
|
||||
{
|
||||
metricName: 'expo.app_startup.tti',
|
||||
eventCount: 50,
|
||||
statistics: { min: 0, max: 1, median: 0.5, average: 0.5, p80: 0.8, p90: 0.9, p99: 0.95 },
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
const { totalEventCounts } = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(totalEventCounts.get('expo.app_startup.tti:IOS')).toBe(150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
|
||||
|
||||
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appIdentifier: 'com.example.app',
|
||||
appName: 'ExampleApp',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
eventBatchId: 'batch-1',
|
||||
tags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
const withNextPage: PageInfo = {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-abc',
|
||||
};
|
||||
|
||||
describe(buildObserveEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.85,
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
deviceOsVersion: '14',
|
||||
deviceModel: 'Pixel 8',
|
||||
countryCode: 'PL',
|
||||
timestamp: '2025-01-14T08:15:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// Escape codes are included, because the header is bolded.
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mValue App Version Platform Device Country Timestamp [22m
|
||||
----- ----------- ---------- --------- ------- ----------------------
|
||||
1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
|
||||
0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns yellow warning for empty array', () => {
|
||||
const output = buildObserveEventsTable([], noNextPage);
|
||||
expect(output).toContain('No events found.');
|
||||
});
|
||||
|
||||
it('shows metric name in summary header when options are provided', () => {
|
||||
const events = [createMockEvent({ metricName: 'expo.app_startup.tti' })];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
daysBack: 30,
|
||||
});
|
||||
|
||||
expect(output).toContain('TTI events for the last 30 days');
|
||||
});
|
||||
|
||||
it('shows date range in summary header when start/end provided', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage, {
|
||||
metricName: 'expo.app_startup.cold_launch_time',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(output).toContain('Cold Launch events from Jan 1, 2025 to Feb 1, 2025');
|
||||
});
|
||||
|
||||
it('shows - for null countryCode', () => {
|
||||
const events = [createMockEvent({ countryCode: null })];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// The country column should contain a dash
|
||||
const lines = output.split('\n');
|
||||
const dataLine = lines[2]; // header, separator, first data row
|
||||
expect(dataLine).toContain('-');
|
||||
});
|
||||
|
||||
it('appends next page hint when hasNextPage is true', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, withNextPage);
|
||||
|
||||
expect(output).toContain('Next page: --after cursor-abc');
|
||||
});
|
||||
|
||||
it('does not append next page hint when hasNextPage is false', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
expect(output).not.toContain('Next page');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveEventsJson, () => {
|
||||
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, withNextPage);
|
||||
|
||||
expect(result).toEqual({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: 'cursor-abc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null optional fields', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
countryCode: null,
|
||||
sessionId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, noNextPage);
|
||||
|
||||
expect(result.events[0].countryCode).toBeNull();
|
||||
expect(result.events[0].sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty events array for empty input', () => {
|
||||
const result = buildObserveEventsJson([], noNextPage);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import {
|
||||
ObserveMetricsMap,
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
makeMetricsKey,
|
||||
resolveStatKey,
|
||||
type MetricValues,
|
||||
} from '../formatMetrics';
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
|
||||
|
||||
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
|
||||
return {
|
||||
min: 0.1,
|
||||
median: 0.3,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveMetricsTable, () => {
|
||||
it('formats metrics grouped by version with metric columns', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
iosKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
|
||||
metricsMap.set(
|
||||
androidKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
// The header is bolded, thus the escape characters in the snapshot
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- ----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
|
||||
[1mAndroid[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----------
|
||||
1.1.0 0.25s (120) 1.12s (100)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('shows - for versions with no matching metric data', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----
|
||||
2.0.0 - (-) - (-)"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns message when no metrics data found', () => {
|
||||
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
expect(output).toMatchInlineSnapshot(`"[33mNo metrics data found.[39m"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveMetricsJson, () => {
|
||||
it('produces JSON with all stats per metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions).toHaveLength(1);
|
||||
expect(result.versions[0]).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
platform: AppPlatform.Ios,
|
||||
buildNumbers: [],
|
||||
updateIds: [],
|
||||
metrics: {
|
||||
'expo.app_startup.tti': {
|
||||
min: 0.1,
|
||||
median: 0.12,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('produces null values when no observe data matches for a metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics).toEqual({
|
||||
'expo.app_startup.tti': {
|
||||
min: null,
|
||||
median: null,
|
||||
max: null,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(makeMetricsKey, () => {
|
||||
it('creates a key from version and platform', () => {
|
||||
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveStatKey, () => {
|
||||
it('resolves canonical stat names', () => {
|
||||
expect(resolveStatKey('min')).toBe('min');
|
||||
expect(resolveStatKey('max')).toBe('max');
|
||||
expect(resolveStatKey('median')).toBe('median');
|
||||
expect(resolveStatKey('average')).toBe('average');
|
||||
expect(resolveStatKey('p80')).toBe('p80');
|
||||
expect(resolveStatKey('p90')).toBe('p90');
|
||||
expect(resolveStatKey('p99')).toBe('p99');
|
||||
expect(resolveStatKey('eventCount')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('resolves short aliases', () => {
|
||||
expect(resolveStatKey('med')).toBe('median');
|
||||
expect(resolveStatKey('avg')).toBe('average');
|
||||
expect(resolveStatKey('count')).toBe('eventCount');
|
||||
expect(resolveStatKey('event_count')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_TABLE', () => {
|
||||
it('defaults to median, eventCount', () => {
|
||||
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_JSON', () => {
|
||||
it('includes all stats', () => {
|
||||
expect(DEFAULT_STATS_JSON).toEqual([
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom stats parameter', () => {
|
||||
it('table renders only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p99', 'eventCount']
|
||||
);
|
||||
|
||||
expect(output).toContain('TTI');
|
||||
expect(output).toContain('0.90s (42)');
|
||||
expect(output).not.toContain('TTI Min');
|
||||
expect(output).not.toContain('TTI Med');
|
||||
expect(output).not.toContain('TTI Max');
|
||||
});
|
||||
|
||||
it("table formats eventCount as integer without 's' suffix", () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: 100,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
|
||||
|
||||
expect(output).toContain('100');
|
||||
expect(output).not.toContain('100s');
|
||||
expect(output).not.toContain('100.00s');
|
||||
});
|
||||
|
||||
it('JSON includes only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
['p90', 'eventCount']
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
p90: 0.4,
|
||||
eventCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON uses default stats when not specified', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result.versions[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
|
||||
});
|
||||
|
||||
it('resolves short alias "ttr" to full metric name', () => {
|
||||
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
|
||||
});
|
||||
|
||||
it('resolves short alias "cold_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "warm_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "bundle_load" to full metric name', () => {
|
||||
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
|
||||
});
|
||||
|
||||
it('passes through full metric names unchanged', () => {
|
||||
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
|
||||
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
|
||||
'expo.app_startup.cold_launch_time'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unknown alias', () => {
|
||||
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
|
||||
});
|
||||
|
||||
it('passes through dot-containing custom metric names', () => {
|
||||
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export enum EventsOrderPreset {
|
||||
Slowest = 'SLOWEST',
|
||||
Fastest = 'FASTEST',
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST',
|
||||
}
|
||||
|
||||
export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
|
||||
const preset = input.toUpperCase() as EventsOrderPreset;
|
||||
switch (preset) {
|
||||
case EventsOrderPreset.Slowest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Fastest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
case EventsOrderPreset.Newest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case EventsOrderPreset.Oldest:
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchObserveEventsOptions {
|
||||
metricName: string;
|
||||
orderBy: AppObserveEventsOrderBy;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
}
|
||||
|
||||
interface FetchObserveEventsResult {
|
||||
events: AppObserveEvent[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export async function fetchObserveEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveEventsOptions
|
||||
): Promise<FetchObserveEventsResult> {
|
||||
const filter: AppObserveEventsFilter = {
|
||||
metricName: options.metricName,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.eventsAsync(graphqlClient, {
|
||||
appId,
|
||||
filter,
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: options.orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export async function fetchTotalEventCountAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricName: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<number> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
try {
|
||||
const versions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: appPlatformToObservePlatform[appPlatform],
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames: [metricName],
|
||||
});
|
||||
return versions.reduce((sum, v) => {
|
||||
const metric = v.metrics.find(m => m.metricName === metricName);
|
||||
return sum + (metric?.eventCount ?? 0);
|
||||
}, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const counts = await Promise.all(queries);
|
||||
return counts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
BuildNumbersMap,
|
||||
MetricValues,
|
||||
ObserveMetricsMap,
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new EasCommandError(
|
||||
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchObserveMetricsResult {
|
||||
metricsMap: ObserveMetricsMap;
|
||||
buildNumbersMap: BuildNumbersMap;
|
||||
updateIdsMap: UpdateIdsMap;
|
||||
totalEventCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
export async function fetchObserveMetricsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricNames: string[],
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<FetchObserveMetricsResult> {
|
||||
const queries = platforms.map(async appPlatform => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
metricNames,
|
||||
});
|
||||
return { appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const buildNumbersMap: BuildNumbersMap = new Map();
|
||||
const updateIdsMap: UpdateIdsMap = new Map();
|
||||
const totalEventCounts = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
const { appPlatform, appVersions } = result;
|
||||
|
||||
for (const version of appVersions) {
|
||||
const key = makeMetricsKey(version.appVersion, appPlatform);
|
||||
if (!metricsMap.has(key)) {
|
||||
metricsMap.set(key, new Map());
|
||||
}
|
||||
if (!buildNumbersMap.has(key)) {
|
||||
buildNumbersMap.set(
|
||||
key,
|
||||
version.buildNumbers.map(bn => bn.appBuildNumber)
|
||||
);
|
||||
}
|
||||
if (!updateIdsMap.has(key)) {
|
||||
updateIdsMap.set(
|
||||
key,
|
||||
version.updates.map(u => u.appUpdateId)
|
||||
);
|
||||
}
|
||||
|
||||
for (const metric of version.metrics) {
|
||||
const values: MetricValues = {
|
||||
min: metric.statistics.min,
|
||||
max: metric.statistics.max,
|
||||
median: metric.statistics.median,
|
||||
average: metric.statistics.average,
|
||||
p80: metric.statistics.p80,
|
||||
p90: metric.statistics.p90,
|
||||
p99: metric.statistics.p99,
|
||||
eventCount: metric.eventCount,
|
||||
};
|
||||
metricsMap.get(key)!.set(metric.metricName, values);
|
||||
|
||||
const eventCountKey = `${metric.metricName}:${appPlatform}`;
|
||||
totalEventCounts.set(
|
||||
eventCountKey,
|
||||
(totalEventCounts.get(eventCountKey) ?? 0) + metric.eventCount
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
export interface AppVersionsResult {
|
||||
platform: AppPlatform;
|
||||
appVersions: AppObserveAppVersion[];
|
||||
}
|
||||
|
||||
export async function fetchObserveVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<AppVersionsResult[]> {
|
||||
const queries = platforms.map(async (appPlatform): Promise<AppVersionsResult | null> => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const appVersions = await ObserveQuery.appVersionsAsync(graphqlClient, {
|
||||
appId,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
return { platform: appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch app versions for ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
return results.filter((r): r is AppVersionsResult => r !== null);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveEvent, PageInfo } from '../graphql/generated';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export interface ObserveEventJson {
|
||||
id: string;
|
||||
metricName: string;
|
||||
metricValue: number;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
sessionId: string | null;
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildEventsTableOptions {
|
||||
metricName: string;
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
totalEventCount?: number;
|
||||
}
|
||||
|
||||
export function buildObserveEventsTable(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo,
|
||||
options?: BuildEventsTableOptions
|
||||
): string {
|
||||
if (events.length === 0) {
|
||||
return chalk.yellow('No events found.');
|
||||
}
|
||||
|
||||
const hasUpdates = events.some(e => e.appUpdateId);
|
||||
|
||||
const headers = [
|
||||
'Value',
|
||||
'App Version',
|
||||
...(hasUpdates ? ['Update'] : []),
|
||||
'Platform',
|
||||
'Device',
|
||||
'Country',
|
||||
'Timestamp',
|
||||
];
|
||||
|
||||
const rows: string[][] = events.map(event => [
|
||||
`${event.metricValue.toFixed(2)}s`,
|
||||
`${event.appVersion} (${event.appBuildNumber})`,
|
||||
...(hasUpdates ? [event.appUpdateId ?? '-'] : []),
|
||||
`${event.deviceOs} ${event.deviceOsVersion}`,
|
||||
event.deviceModel,
|
||||
event.countryCode ?? '-',
|
||||
formatTimestamp(event.timestamp),
|
||||
]);
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options) {
|
||||
const metricDisplay = getMetricDisplayName(options.metricName);
|
||||
let timeDesc: string;
|
||||
if (options.daysBack) {
|
||||
timeDesc = `for the last ${options.daysBack} days`;
|
||||
} else if (options.startTime && options.endTime) {
|
||||
timeDesc = `from ${formatDate(options.startTime)} to ${formatDate(options.endTime)}`;
|
||||
} else {
|
||||
timeDesc = '';
|
||||
}
|
||||
const totalDesc =
|
||||
options.totalEventCount != null
|
||||
? ` — ${options.totalEventCount.toLocaleString()} total events`
|
||||
: '';
|
||||
lines.push(chalk.bold(`${metricDisplay} events ${timeDesc}${totalDesc}`.trim()), '');
|
||||
}
|
||||
|
||||
lines.push(chalk.bold(headerLine), separatorLine, ...dataLines);
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveEventsJson(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo
|
||||
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
metricName: event.metricName,
|
||||
metricValue: event.metricValue,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type StatisticKey =
|
||||
| 'min'
|
||||
| 'max'
|
||||
| 'median'
|
||||
| 'average'
|
||||
| 'p80'
|
||||
| 'p90'
|
||||
| 'p99'
|
||||
| 'eventCount';
|
||||
|
||||
export const STAT_ALIASES: Record<string, StatisticKey> = {
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
med: 'median',
|
||||
median: 'median',
|
||||
avg: 'average',
|
||||
average: 'average',
|
||||
p80: 'p80',
|
||||
p90: 'p90',
|
||||
p99: 'p99',
|
||||
count: 'eventCount',
|
||||
event_count: 'eventCount',
|
||||
eventCount: 'eventCount',
|
||||
};
|
||||
|
||||
export const STAT_DISPLAY_NAMES: Record<StatisticKey, string> = {
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
median: 'Med',
|
||||
average: 'Avg',
|
||||
p80: 'P80',
|
||||
p90: 'P90',
|
||||
p99: 'P99',
|
||||
eventCount: 'Count',
|
||||
};
|
||||
|
||||
export function resolveStatKey(input: string): StatisticKey {
|
||||
const resolved = STAT_ALIASES[input];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatValue(stat: StatisticKey, value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (stat === 'eventCount') {
|
||||
return String(value);
|
||||
}
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatMergedCell(
|
||||
stat: StatisticKey,
|
||||
statValue: number | null | undefined,
|
||||
eventCount: number | null | undefined
|
||||
): string {
|
||||
const formatted = formatStatValue(stat, statValue);
|
||||
const count = eventCount != null ? String(eventCount) : '-';
|
||||
return `${formatted} (${count})`;
|
||||
}
|
||||
|
||||
export interface MetricValues {
|
||||
min: number | null | undefined;
|
||||
max: number | null | undefined;
|
||||
median: number | null | undefined;
|
||||
average: number | null | undefined;
|
||||
p80: number | null | undefined;
|
||||
p90: number | null | undefined;
|
||||
p99: number | null | undefined;
|
||||
eventCount: number | null | undefined;
|
||||
}
|
||||
|
||||
type ObserveMetricsKey = `${string}:${AppPlatform}`;
|
||||
|
||||
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
|
||||
export type BuildNumbersMap = Map<ObserveMetricsKey, string[]>;
|
||||
export type UpdateIdsMap = Map<ObserveMetricsKey, string[]>;
|
||||
|
||||
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
|
||||
return `${appVersion}:${platform}`;
|
||||
}
|
||||
|
||||
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
|
||||
const lastColon = key.lastIndexOf(':');
|
||||
return {
|
||||
appVersion: key.slice(0, lastColon),
|
||||
platform: key.slice(lastColon + 1) as AppPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
|
||||
|
||||
export interface ObserveMetricsVersionResult {
|
||||
appVersion: string;
|
||||
platform: AppPlatform;
|
||||
buildNumbers: string[];
|
||||
updateIds: string[];
|
||||
metrics: Record<string, MetricValuesJson>;
|
||||
}
|
||||
|
||||
export interface ObserveMetricsJsonOutput {
|
||||
versions: ObserveMetricsVersionResult[];
|
||||
totalEventCounts: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export function buildObserveMetricsJson(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[],
|
||||
totalEventCounts?: Map<string, number>,
|
||||
buildNumbersMap?: BuildNumbersMap,
|
||||
updateIdsMap?: UpdateIdsMap
|
||||
): ObserveMetricsJsonOutput {
|
||||
const versions: ObserveMetricsVersionResult[] = [];
|
||||
|
||||
for (const [key, versionMetrics] of metricsMap) {
|
||||
const { appVersion, platform } = parseMetricsKey(key);
|
||||
|
||||
const metrics: Record<string, MetricValuesJson> = {};
|
||||
for (const metricName of metricNames) {
|
||||
const values = versionMetrics.get(metricName);
|
||||
const statValues: MetricValuesJson = {};
|
||||
for (const stat of stats) {
|
||||
statValues[stat] = values?.[stat] ?? null;
|
||||
}
|
||||
metrics[metricName] = statValues;
|
||||
}
|
||||
|
||||
versions.push({
|
||||
appVersion,
|
||||
platform,
|
||||
buildNumbers: buildNumbersMap?.get(key) ?? [],
|
||||
updateIds: updateIdsMap?.get(key) ?? [],
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
// Group total event counts by metric → platform
|
||||
const counts: Record<string, Record<string, number>> = {};
|
||||
if (totalEventCounts) {
|
||||
for (const [key, count] of totalEventCounts) {
|
||||
const lastColon = key.lastIndexOf(':');
|
||||
const metricName = key.slice(0, lastColon);
|
||||
const platform = key.slice(lastColon + 1);
|
||||
if (!counts[metricName]) {
|
||||
counts[metricName] = {};
|
||||
}
|
||||
counts[metricName][platform] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return { versions, totalEventCounts: counts };
|
||||
}
|
||||
|
||||
function buildStatsDescription(displayStats: StatisticKey[]): string {
|
||||
return displayStats.map(s => STAT_DISPLAY_NAMES[s]).join(', ');
|
||||
}
|
||||
|
||||
function buildTimeRangeDescription(daysBack?: number): string {
|
||||
if (daysBack) {
|
||||
return `for the last ${daysBack} days`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][], footerRow?: string[]): string {
|
||||
const allRows = footerRow ? [...rows, footerRow] : rows;
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
|
||||
);
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
|
||||
if (footerRow) {
|
||||
lines.push(separatorLine);
|
||||
lines.push(footerRow.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveMetricsTable(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[],
|
||||
options?: {
|
||||
daysBack?: number;
|
||||
buildNumbersMap?: BuildNumbersMap;
|
||||
totalEventCounts?: Map<string, number>;
|
||||
}
|
||||
): string {
|
||||
const { versions: results } = buildObserveMetricsJson(metricsMap, metricNames, stats);
|
||||
|
||||
if (results.length === 0) {
|
||||
return chalk.yellow('No metrics data found.');
|
||||
}
|
||||
|
||||
const displayStats = stats.filter(s => s !== 'eventCount');
|
||||
const hasEventCount = stats.includes('eventCount');
|
||||
|
||||
// Build summary header
|
||||
const statsDesc = displayStats.length > 0 ? buildStatsDescription(displayStats) : 'Event count';
|
||||
const timeDesc = buildTimeRangeDescription(options?.daysBack);
|
||||
const countSuffix = hasEventCount && displayStats.length > 0 ? ' (event count)' : '';
|
||||
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
|
||||
|
||||
// Group results by platform
|
||||
const byPlatform = new Map<AppPlatform, ObserveMetricsVersionResult[]>();
|
||||
for (const result of results) {
|
||||
if (!byPlatform.has(result.platform)) {
|
||||
byPlatform.set(result.platform, []);
|
||||
}
|
||||
byPlatform.get(result.platform)!.push(result);
|
||||
}
|
||||
|
||||
// Build metric column headers
|
||||
const metricHeaders: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const name = getMetricDisplayName(m);
|
||||
if (displayStats.length > 0 && hasEventCount) {
|
||||
// Merged mode: one column per metric
|
||||
metricHeaders.push(name);
|
||||
} else {
|
||||
// Separate columns per stat
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['eventCount'] as StatisticKey[])) {
|
||||
metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers = ['App Version', ...metricHeaders];
|
||||
|
||||
const sections: string[] = [chalk.bold(summaryLine)];
|
||||
|
||||
for (const [platform, platformResults] of byPlatform) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (const result of platformResults) {
|
||||
const key = makeMetricsKey(result.appVersion, result.platform);
|
||||
const buildNumbers = options?.buildNumbersMap?.get(key);
|
||||
const versionLabel = buildNumbers?.length
|
||||
? `${result.appVersion} (${buildNumbers.join(', ')})`
|
||||
: result.appVersion;
|
||||
|
||||
const metricCells: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const values = result.metrics[m];
|
||||
if (displayStats.length > 0 && hasEventCount) {
|
||||
for (const stat of displayStats) {
|
||||
metricCells.push(
|
||||
formatMergedCell(stat, values?.[stat] ?? null, values?.eventCount ?? null)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['eventCount'] as StatisticKey[])) {
|
||||
metricCells.push(formatStatValue(stat, values?.[stat] ?? null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.push([versionLabel, ...metricCells]);
|
||||
}
|
||||
|
||||
let footerRow: string[] | undefined;
|
||||
if (options?.totalEventCounts) {
|
||||
const countCells: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const count = options.totalEventCounts.get(`${m}:${platform}`);
|
||||
countCells.push(count != null ? count.toLocaleString() : '-');
|
||||
}
|
||||
if (countCells.some(c => c !== '-')) {
|
||||
footerRow = ['Total events', ...countCells];
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(renderTable(headers, rows, footerRow));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AppObserveAppBuildNumber,
|
||||
AppObserveAppEasBuild,
|
||||
AppObserveAppUpdate,
|
||||
} from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { AppVersionsResult } from './fetchVersions';
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export interface AppVersionJson {
|
||||
platform: string;
|
||||
appVersion: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
buildNumbers: AppBuildNumberJson[];
|
||||
updates: AppUpdateJson[];
|
||||
}
|
||||
|
||||
export interface AppBuildNumberJson {
|
||||
appBuildNumber: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppUpdateJson {
|
||||
appUpdateId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
easBuilds: AppEasBuildJson[];
|
||||
}
|
||||
|
||||
export interface AppEasBuildJson {
|
||||
easBuildId: string;
|
||||
firstSeenAt: string;
|
||||
eventCount: number;
|
||||
uniqueUserCount: number;
|
||||
}
|
||||
|
||||
function mapEasBuilds(easBuilds: AppObserveAppEasBuild[]): AppEasBuildJson[] {
|
||||
return easBuilds.map(b => ({
|
||||
easBuildId: b.easBuildId,
|
||||
firstSeenAt: b.firstSeenAt,
|
||||
eventCount: b.eventCount,
|
||||
uniqueUserCount: b.uniqueUserCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapBuildNumbers(buildNumbers: AppObserveAppBuildNumber[]): AppBuildNumberJson[] {
|
||||
return buildNumbers.map(bn => ({
|
||||
appBuildNumber: bn.appBuildNumber,
|
||||
firstSeenAt: bn.firstSeenAt,
|
||||
eventCount: bn.eventCount,
|
||||
uniqueUserCount: bn.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(bn.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
function mapUpdates(updates: AppObserveAppUpdate[]): AppUpdateJson[] {
|
||||
return updates.map(u => ({
|
||||
appUpdateId: u.appUpdateId,
|
||||
firstSeenAt: u.firstSeenAt,
|
||||
eventCount: u.eventCount,
|
||||
uniqueUserCount: u.uniqueUserCount,
|
||||
easBuilds: mapEasBuilds(u.easBuilds),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildObserveVersionsJson(results: AppVersionsResult[]): AppVersionJson[] {
|
||||
const output: AppVersionJson[] = [];
|
||||
for (const { platform, appVersions } of results) {
|
||||
for (const v of appVersions) {
|
||||
output.push({
|
||||
platform: platform as string,
|
||||
appVersion: v.appVersion,
|
||||
firstSeenAt: v.firstSeenAt,
|
||||
eventCount: v.eventCount,
|
||||
uniqueUserCount: v.uniqueUserCount,
|
||||
buildNumbers: mapBuildNumbers(v.buildNumbers),
|
||||
updates: mapUpdates(v.updates),
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveVersionsTable(results: AppVersionsResult[]): string {
|
||||
const hasAnyVersions = results.some(r => r.appVersions.length > 0);
|
||||
|
||||
if (!hasAnyVersions) {
|
||||
return chalk.yellow('No app versions found.');
|
||||
}
|
||||
|
||||
const headers = ['App Version', 'First Seen', 'Events', 'Users', 'Builds', 'Updates'];
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const { platform, appVersions } of results) {
|
||||
if (appVersions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sections.length > 0) {
|
||||
sections.push('');
|
||||
}
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = appVersions.map(version => [
|
||||
version.appVersion,
|
||||
formatDate(version.firstSeenAt),
|
||||
String(version.eventCount),
|
||||
String(version.uniqueUserCount),
|
||||
String(version.buildNumbers.length),
|
||||
String(version.updates.length),
|
||||
]);
|
||||
|
||||
sections.push(renderTable(headers, rows));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
|
||||
export const METRIC_ALIASES: Record<string, string> = {
|
||||
tti: 'expo.app_startup.tti',
|
||||
ttr: 'expo.app_startup.ttr',
|
||||
cold_launch: 'expo.app_startup.cold_launch_time',
|
||||
warm_launch: 'expo.app_startup.warm_launch_time',
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
if (METRIC_ALIASES[input]) {
|
||||
return METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { validateDateFlag } from './fetchMetrics';
|
||||
|
||||
export const DEFAULT_DAYS_BACK = 60;
|
||||
|
||||
export function startAndEndTime({
|
||||
daysBack,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
daysBack?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}) {
|
||||
let startTime: string;
|
||||
let endTime: string;
|
||||
|
||||
if (daysBack) {
|
||||
endTime = new Date().toISOString();
|
||||
startTime = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
endTime = end ?? new Date().toISOString();
|
||||
startTime =
|
||||
start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
export function resolveTimeRange(flags: { days?: number; start?: string; end?: string }): {
|
||||
daysBack?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} {
|
||||
if (flags.start) {
|
||||
validateDateFlag(flags.start, '--start');
|
||||
}
|
||||
if (flags.end) {
|
||||
validateDateFlag(flags.end, '--end');
|
||||
}
|
||||
|
||||
const daysBack = flags.days ?? (flags.start ? undefined : DEFAULT_DAYS_BACK);
|
||||
const { startTime, endTime } = startAndEndTime({
|
||||
daysBack,
|
||||
start: flags.start,
|
||||
end: flags.end,
|
||||
});
|
||||
|
||||
return { daysBack, startTime, endTime };
|
||||
}
|
||||
@@ -85,6 +85,16 @@ export default class SessionManager {
|
||||
}
|
||||
|
||||
public async logoutAsync(): Promise<void> {
|
||||
const sessionSecret = this.getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/logout', { body: {} });
|
||||
} catch (e) {
|
||||
// Best-effort: clear the local session even if the server request fails
|
||||
Log.debug('Failed to invalidate session secret on server:', e);
|
||||
}
|
||||
}
|
||||
this.currentActor = undefined;
|
||||
await this.setSessionAsync(undefined);
|
||||
}
|
||||
|
||||
@@ -219,6 +219,38 @@ describe(SessionManager, () => {
|
||||
await sessionManager.logoutAsync();
|
||||
expect(sessionManager['getSessionSecret']()).toBe(null);
|
||||
});
|
||||
|
||||
it('calls the server logout endpoint when session secret exists', async () => {
|
||||
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
|
||||
sessionSecret: 'SESSION_SECRET',
|
||||
id: 'USER_ID',
|
||||
username: 'USERNAME',
|
||||
});
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager['loginAsync']({ username: 'USERNAME', password: 'PASSWORD' });
|
||||
await sessionManager.logoutAsync();
|
||||
|
||||
expect(apiV2PostSpy).toHaveBeenCalledWith('auth/logout', { body: {} });
|
||||
});
|
||||
|
||||
it('clears the local session even if the server logout call fails', async () => {
|
||||
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
|
||||
sessionSecret: 'SESSION_SECRET',
|
||||
id: 'USER_ID',
|
||||
username: 'USERNAME',
|
||||
});
|
||||
jest
|
||||
.spyOn(ApiV2Client.prototype, 'postAsync')
|
||||
.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager['loginAsync']({ username: 'USERNAME', password: 'PASSWORD' });
|
||||
await sessionManager.logoutAsync();
|
||||
|
||||
expect(sessionManager['getSessionSecret']()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showLoginPromptAsync', () => {
|
||||
|
||||
@@ -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.4.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.4.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.4.0",
|
||||
"version": "18.7.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,11 +26,11 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.4.0",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.4.0",
|
||||
"version": "18.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -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/template-file",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.4.0",
|
||||
"version": "18.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,18 +19,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.4.0",
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/downloader": "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.4.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"@sentry/node": "7.77.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
|
||||
@@ -2847,33 +2847,33 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/apple-utils@npm:2.1.16":
|
||||
version: 2.1.16
|
||||
resolution: "@expo/apple-utils@npm:2.1.16"
|
||||
"@expo/apple-utils@npm:2.1.19":
|
||||
version: 2.1.19
|
||||
resolution: "@expo/apple-utils@npm:2.1.19"
|
||||
bin:
|
||||
apple-utils: bin.js
|
||||
checksum: 10c0/9d3915f3f25c32e1f1ab323676cabccc2998046be6f30deebc68a0706836bd50bc5145662fa13441e0283f82c56e50f55002d50dcc43aa78e9fa7327d1676a54
|
||||
checksum: 10c0/42eb1038bd0906d53b37d830499c7a8ee7ec9f9b9ef4078ff898ca84cb5af1e6ac39d3a392818825151aa2ea237a44698f539b973936733350f314efff020858
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.4.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.7.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:18.0.1"
|
||||
"@expo/eas-build-job": "npm:18.4.0"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.2.0"
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.4.0"
|
||||
"@expo/template-file": "npm:18.0.1"
|
||||
"@expo/turtle-spawn": "npm:18.0.1"
|
||||
"@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"
|
||||
"@google-cloud/storage": "npm:^7.11.2"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
@@ -3095,7 +3095,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:18.0.1, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:18.5.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -3111,11 +3111,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:18.4.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:
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -3128,12 +3128,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:18.4.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.4.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"
|
||||
@@ -3270,7 +3270,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:18.0.1, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3481,12 +3481,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:18.4.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.4.0"
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@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"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3511,7 +3511,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:18.0.1, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:18.5.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3534,11 +3534,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:18.0.1, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:18.5.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -3554,18 +3554,18 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.4.0"
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:18.0.1"
|
||||
"@expo/eas-build-job": "npm:18.4.0"
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/downloader": "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.4.0"
|
||||
"@expo/template-file": "npm:18.0.1"
|
||||
"@expo/turtle-spawn": "npm:18.0.1"
|
||||
"@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"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
@@ -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.4.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,11 +10944,11 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.4.0"
|
||||
"@expo/eas-build-job": "npm:18.4.0"
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:18.0.1"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@types/bunyan": "npm:^1.8.11"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
@@ -10998,15 +10998,15 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli@workspace:packages/eas-cli"
|
||||
dependencies:
|
||||
"@expo/apple-utils": "npm:2.1.16"
|
||||
"@expo/apple-utils": "npm:2.1.19"
|
||||
"@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.4.0"
|
||||
"@expo/eas-json": "npm:18.4.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.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -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.4.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