Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 9d93c6ed94 feat(metadata): support privacy nutrition labels (data usage)
Adds a new `privacy.dataUsage` block to the eas-cli metadata schema and
a `DataUsageTask` that pushes/pulls App Privacy Nutrition Labels via the
@expo/apple-utils AppDataUsage* models. After a successful push the
publish state is flipped to published so the changes take effect.

Apple has required every new app submission since 2021 to declare what
data the app collects, so this closes the biggest remaining gap in
`eas metadata`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:41:12 -07:00
138 changed files with 1864 additions and 16765 deletions

No files matched your search

+2 -43
View File
@@ -8,58 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- Fix EAS Workflows interpolation throwing `Unexpected "}" at character N` when a single value contains multiple `${{ ... }}` expressions (for example, `changelog: ${{ a }}: ${{ b }}`). ([#3643](https://github.com/expo/eas-cli/pull/3643) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [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))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [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))
### 🧹 Chores
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
@@ -67,7 +27,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.8.1",
"version": "18.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.8.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-build-job": "18.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
+2 -16
View File
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -52,7 +51,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -148,10 +146,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
return await runFastlaneGym(ctx, {
await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -173,18 +171,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -1,72 +0,0 @@
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,11 @@
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import {
BuildJob,
EasCliNpmTags,
Env,
Metadata,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -7,7 +14,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
import { isAtLeastNpm7Async } from '../utils/packageManager';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -114,6 +121,33 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
+5 -2
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,7 +82,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
+1 -3
View File
@@ -173,9 +173,7 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
@@ -22,10 +22,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -49,10 +45,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -78,10 +70,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -109,10 +97,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -133,9 +117,6 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,9 +134,6 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -175,8 +153,5 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -1,128 +0,0 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,8 +58,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -96,8 +94,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -135,8 +131,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -198,8 +192,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -218,7 +210,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -235,7 +226,6 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -253,7 +243,6 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
+2 -16
View File
@@ -3,7 +3,6 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
@@ -29,13 +28,7 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
): Promise<void> {
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -50,15 +43,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -131,8 +122,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -142,7 +131,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -154,8 +142,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
-9
View File
@@ -11,8 +11,6 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -23,7 +21,6 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -37,8 +34,6 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -64,8 +59,6 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -81,7 +74,6 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -94,7 +86,6 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
@@ -1,27 +0,0 @@
import { getEasFunctionGroups } from '../easFunctionGroups';
describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
const ctx = {
hasBuildJob: () => false,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(['eas/maestro_test']);
});
it('includes eas/build for build jobs', () => {
const ctx = {
hasBuildJob: () => true,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});
});
@@ -9,8 +9,6 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
@@ -20,9 +18,7 @@ import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
@@ -32,13 +28,11 @@ import {
createRestoreBuildCacheFunction,
} from './functions/restoreBuildCache';
import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
@@ -53,14 +47,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -77,8 +67,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -1,200 +0,0 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { createEasBuildBuildFunctionGroup } from '../build';
function createMockBuildToolsContext(
overrides: Partial<{
platform: Platform;
simulator: boolean;
buildCredentials: Record<string, unknown>;
}> = {}
): CustomBuildContext<BuildJob> {
return {
job: {
platform: overrides.platform ?? Platform.ANDROID,
simulator: overrides.simulator ?? false,
secrets: overrides.buildCredentials
? { buildCredentials: overrides.buildCredentials }
: undefined,
},
} as unknown as CustomBuildContext<BuildJob>;
}
describe(createEasBuildBuildFunctionGroup, () => {
describe('working_directory input', () => {
it('does not set working directory on steps when not provided (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
for (const step of steps) {
// Without working_directory, only installPods would have a relative dir (iOS only).
// On Android, no step should have a relative working directory.
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
}
});
it('sets working directory on all steps except checkout (Android)', () => {
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
it('sets working directory on all steps except checkout (iOS simulator)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
}
});
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
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 configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
const targetNamesInput = configureIosVersionStep?.inputs?.find(
input => input.id === 'target_names'
);
expect(configureIosVersionStep).toBeDefined();
expect(targetNamesInput?.rawValue).toBe(
'${{ steps.configure_ios_credentials.outputs.target_names }}'
);
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
});
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
simulator: true,
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
expect(installPodsStep).toBeDefined();
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
});
it('sets working directory on all steps except checkout (Android with credentials)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.ANDROID,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
expect(checkoutStep).toBeDefined();
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
for (const step of nonCheckoutSteps) {
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
}
});
});
it('throws for generic jobs (no platform)', () => {
const buildToolsContext = {
job: { platform: undefined },
} as unknown as CustomBuildContext<BuildJob>;
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
'Build function group is not supported in generic jobs.'
);
});
});
@@ -1,12 +1,5 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunctionGroup,
BuildStep,
BuildStepGlobalContext,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
@@ -37,7 +30,6 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
globalCtx: BuildStepGlobalContext;
buildToolsContext: CustomBuildContext<BuildJob>;
workingDirectory?: string;
}
export function createEasBuildBuildFunctionGroup(
@@ -46,30 +38,17 @@ export function createEasBuildBuildFunctionGroup(
return new BuildFunctionGroup({
namespace: 'eas',
id: 'build',
inputProviders: [
BuildStepInput.createProvider({
id: 'working_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
const workingDirectory = inputs.working_directory?.getValue({
interpolationContext: globalCtx.getInterpolationContext(),
}) as string | undefined;
createBuildStepsFromFunctionGroupCall: globalCtx => {
if (buildToolsContext.job.platform === Platform.IOS) {
if (buildToolsContext.job.simulator) {
return createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
@@ -77,13 +56,11 @@ export function createEasBuildBuildFunctionGroup(
return createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
} else {
return createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
});
}
}
@@ -96,19 +73,16 @@ export function createEasBuildBuildFunctionGroup(
function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -117,7 +91,6 @@ function createStepsForIosSimulatorBuild({
});
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -125,26 +98,18 @@ function createStepsForIosSimulatorBuild({
});
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -152,35 +117,29 @@ function createStepsForIosSimulatorBuild({
}),
]
: []),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForIosBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const resolveAppleTeamIdFromCredentials =
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'resolve_apple_team_id_from_credentials',
workingDirectory,
});
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
},
@@ -188,18 +147,16 @@ function createStepsForIosBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
}
);
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
workingDirectory: './ios',
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -209,7 +166,6 @@ function createStepsForIosBuildWithCredentials({
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
credentials: '${ eas.job.secrets.buildCredentials }',
},
@@ -217,7 +173,6 @@ function createStepsForIosBuildWithCredentials({
);
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_fastlane',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -226,7 +181,6 @@ function createStepsForIosBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.IOS,
},
@@ -234,15 +188,10 @@ function createStepsForIosBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
@@ -250,20 +199,11 @@ function createStepsForIosBuildWithCredentials({
calculateEASUpdateRuntimeVersion,
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
},
}),
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -275,29 +215,24 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForAndroidBuildWithoutCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -307,7 +242,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -315,7 +249,6 @@ function createStepsForAndroidBuildWithoutCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -324,7 +257,6 @@ function createStepsForAndroidBuildWithoutCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -332,26 +264,18 @@ function createStepsForAndroidBuildWithoutCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -362,29 +286,24 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
function createStepsForAndroidBuildWithCredentials({
globalCtx,
buildToolsContext,
workingDirectory,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
workingDirectory,
});
const configureEASUpdate =
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
throw_if_not_configured: false,
resolved_eas_update_runtime_version:
@@ -394,7 +313,6 @@ function createStepsForAndroidBuildWithCredentials({
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -402,7 +320,6 @@ function createStepsForAndroidBuildWithCredentials({
);
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'run_gradle',
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -411,7 +328,6 @@ function createStepsForAndroidBuildWithCredentials({
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
platform: Platform.ANDROID,
},
@@ -419,33 +335,21 @@ function createStepsForAndroidBuildWithCredentials({
);
return [
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{ workingDirectory }
globalCtx
),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
restoreCache,
calculateEASUpdateRuntimeVersion,
configureEASUpdate,
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
runGradle,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
resolved_eas_update_runtime_version:
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
@@ -455,10 +359,8 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
}),
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
];
}
@@ -1,81 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { configureCredentialsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
describe(configureIosCredentialsFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
});
it('exports target_names from prepared credentials', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: createTestIosJob() },
}),
{
callInputs: {
credentials: createRawCredentials(),
},
}
);
await buildStep.executeAsync();
expect(configureCredentialsAsync).toHaveBeenCalled();
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
});
});
@@ -1,286 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { updateVersionsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
import { configureIosVersionFunction } from '../configureIosVersion';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
function createBuildStep({
callInputs = {},
job = createTestIosJob(),
}: {
callInputs?: Record<string, unknown>;
job?: ReturnType<typeof createTestIosJob>;
} = {}) {
return configureIosVersionFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job },
}),
{ callInputs }
);
}
describe(configureIosVersionFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
});
it('uses target_names without an explicit credentials input', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 'widget'],
build_number: '42',
},
});
await buildStep.executeAsync();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: '42',
appVersion: undefined,
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses credentials only to preserve the existing fallback path', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
callInputs: {
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses the implicit credentials default fallback from the job secrets', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
callInputs: {
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('prefers target_names when both target_names and credentials are provided', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
const buildStep = createBuildStep({
callInputs: {
target_names: ['input-target'],
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['input-target'],
buildConfiguration: 'Release',
}
);
});
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: {
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
},
});
const configureIosCredentialsStep =
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
});
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
globalCtx,
{
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
app_version: '1.2.3',
},
}
);
await new BuildWorkflow(globalCtx, {
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
buildFunctions: {},
}).executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
JSON.stringify(['app', 'widget'])
);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
configureIosVersionStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('accepts an empty target_names array without crashing', async () => {
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const buildStep = createBuildStep({
callInputs: {
target_names: [],
app_version: '1.2.3',
},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: [],
buildConfiguration: 'Release',
}
);
});
it('rejects invalid target_names', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 123],
app_version: '1.2.3',
},
});
const promise = buildStep.executeAsync();
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
});
it('uses the corrected App version error text', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app'],
app_version: 'invalid-semver',
},
});
await expect(buildStep.executeAsync()).rejects.toThrow(
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
);
});
});
@@ -1,290 +0,0 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { runEasCliCommand } from '../../../utils/easCli';
import { createEasDeployBuildFunction } from '../deploy';
jest.mock('../../../utils/easCli', () => ({
runEasCliCommand: jest.fn(),
}));
const mockDeployStdout = JSON.stringify({
url: 'https://example.dev',
production: { url: 'https://example.prod' },
aliases: [{ url: 'https://example.alias' }],
identifier: 'abc',
dashboardUrl: 'https://expo.dev/dashboard',
});
describe(createEasDeployBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
alias: 'preview-link',
prod: true,
source_maps: false,
},
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
);
expect(runEasCliCommand).toHaveBeenCalledWith({
args: [
'deploy',
'--non-interactive',
'--json',
'--export-dir',
'dist',
'--alias',
'preview-link',
'--prod',
],
options: expect.objectContaining({
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
}),
});
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
});
it('forwards workflow step env to the eas deploy process', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: true },
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--source-maps']),
})
);
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
HOME: '/tmp/home',
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
}),
}),
})
);
});
it('throws an actionable error message when deploy command fails', async () => {
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
let errorMessage = '';
try {
await buildStep.executeAsync();
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toContain('Deploy command failed.');
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
expect(errorMessage).toContain(
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
);
expect(errorMessage).toContain('Original error: deploy failed');
});
it('passes custom export_dir to eas deploy', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { export_dir: 'web-build' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--export-dir', 'web-build']),
})
);
});
it('does not pass --source-maps when source_maps is false', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: false },
});
await buildStep.executeAsync();
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
expect(args).not.toContain('--source-maps');
});
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from('not-json'),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything() }),
expect.stringContaining('Failed to parse')
);
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
});
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
const partialDeployStdout = JSON.stringify({
url: 'https://example.dev-only',
dashboardUrl: 'https://expo.dev/dashboard-only',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(partialDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
});
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
const stdout = JSON.stringify({
url: 'https://example.deployment',
aliases: [{ url: 'https://example.alias-first' }],
identifier: 'partial-alias',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(stdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
});
});
@@ -1,122 +0,0 @@
jest.mock('../../../utils/project', () => ({
runExpoCliCommand: jest.fn().mockResolvedValue({}),
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
}));
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
import { runExpoCliCommand } from '../../../utils/project';
import { createEasExportBuildFunction } from '../export';
describe(createEasExportBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
});
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running export command: expo export --output-dir dist --platform web'
);
expect(runExpoCliCommand).toHaveBeenCalledWith({
packageManager: PackageManager.NPM,
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
options: {
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
stdio: 'pipe',
},
});
expect(buildStep.outputById.export_dir.value).toBe('dist');
});
it('passes optional flags matching expo export CLI', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
output_dir: 'web-out',
dev: true,
minify: false,
dump_assetmap: true,
ssg: false,
api_only: true,
platform: 'web',
},
});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'export',
'--output-dir',
'web-out',
'--platform',
'web',
'--dev',
'--no-minify',
'--dump-assetmap',
'--no-ssg',
'--api-only',
],
})
);
expect(buildStep.outputById.export_dir.value).toBe('web-out');
});
it('uses resolved package manager (e.g. yarn)', async () => {
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({ packageManager: PackageManager.YARN })
);
});
it('throws when export command fails', async () => {
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
});
});
@@ -1,81 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
jest.mock('../../utils/ios/xcactivitylog');
describe(parseXcactivitylogFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
proxyBaseUrl: 'https://cache.example.com',
})
);
});
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
xclogparserVersion: undefined,
})
);
});
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: 'my-app/ios/Build',
workspace_path: 'my-app/ios',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
})
);
});
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: '/abs/derived',
workspace_path: '/abs/workspace',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: '/abs/derived',
workspacePath: '/abs/workspace',
})
);
});
});
@@ -1,77 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAppConfig } from '../../../utils/appConfig';
import { createReadAppConfigBuildFunction } from '../readAppConfig';
jest.mock('../../../utils/appConfig');
describe(createReadAppConfigBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('reads and logs app configuration', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({
logger,
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
});
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
await buildStep.executeAsync();
expect(readAppConfig).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: globalContext.defaultWorkingDirectory,
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
logger: stepLogger,
sdkVersion: '54.0.0',
})
);
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
expect(stepLogger.info).toHaveBeenCalledWith(
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
);
const firstOutput = buildStep.outputById.app_config.value;
expect(firstOutput).toBeDefined();
expect(JSON.parse(firstOutput!)).toEqual({
name: 'my-app',
slug: 'my-app-slug',
version: '1.2.3',
});
});
it('does not throw if reading app config fails', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
});
it('exports app fields as returned by app config', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep =
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest
.mocked(readAppConfig)
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
await buildStep.executeAsync();
const secondOutput = buildStep.outputById.app_config.value;
expect(secondOutput).toBeDefined();
expect(JSON.parse(secondOutput!)).toEqual({
name: 'my-app',
slug: 123,
});
});
});
@@ -1,50 +0,0 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { readAndLogPackageJson } from '../../../utils/project';
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
jest.mock('../../../utils/project');
describe(createReadPackageJsonBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('logs package.json contents', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
await buildStep.executeAsync();
expect(readAndLogPackageJson).toHaveBeenCalledWith(
stepLogger,
globalContext.defaultWorkingDirectory
);
expect(buildStep.outputById.package_json.value).toBe(
JSON.stringify({ name: 'app', version: '1.0.0' })
);
});
it('does not throw if reading package.json fails', async () => {
const stepLogger = createMockLogger();
const logger = createMockLogger();
logger.child = jest.fn().mockReturnValue(stepLogger);
const globalContext = createGlobalContextMock({ logger });
const buildStep =
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
throw new Error('failed to read package.json');
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(stepLogger.error).toHaveBeenCalledWith({
err: expect.any(Error),
});
expect(buildStep.outputById.package_json.value).toBeUndefined();
});
});
@@ -34,6 +34,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
@@ -211,64 +212,4 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
it('fails early on Linux when emulator host support check fails', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
});
it('continues startup on Linux when emulator host support check succeeds', async () => {
await createStep().executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('skips emulator host support check when opt out env var is enabled', async () => {
const step = createStep(undefined, {
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
});
await step.executeAsync();
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
false
);
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
});
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: undefined,
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
env: expect.any(Object),
});
});
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
await createStep(undefined, {
ANDROID_HOME: undefined,
ANDROID_SDK_ROOT: '/android/sdk/root',
}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'/android/sdk/root/emulator/emulator',
['-accel-check'],
{
env: expect.any(Object),
}
);
});
});
@@ -1,10 +1,5 @@
import { Ios } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import assert from 'assert';
import { configureCredentialsAsync } from '../utils/ios/configure';
@@ -31,14 +26,9 @@ export function configureIosCredentialsFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'target_names',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
stripUnknown: true,
convert: true,
abortEarly: false,
@@ -60,7 +50,6 @@ export function configureIosCredentialsFunction(): BuildFunction {
inputs.build_configuration.value as string | undefined
),
});
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
stepCtx.logger.info('Successfully configured iOS credentials');
},
@@ -1,8 +1,7 @@
import { Ios, UserError } from '@expo/eas-build-job';
import { Ios } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import assert from 'assert';
import semver from 'semver';
import { z } from 'zod';
import { updateVersionsAsync } from '../utils/ios/configure';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -18,15 +17,10 @@ export function configureIosVersionFunction(): BuildFunction {
inputProviders: [
BuildStepInput.createProvider({
id: 'credentials',
required: false,
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
defaultValue: '${ eas.job.secrets.buildCredentials }',
}),
BuildStepInput.createProvider({
id: 'target_names',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'build_configuration',
required: false,
@@ -44,6 +38,19 @@ export function configureIosVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
assert(stepCtx.global.staticContext.job, 'Job is not defined');
const job = stepCtx.global.staticContext.job as Ios.Job;
@@ -52,9 +59,8 @@ export function configureIosVersionFunction(): BuildFunction {
const appVersion =
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
if (appVersion && !semver.valid(appVersion)) {
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
throw new Error(
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
);
}
@@ -65,33 +71,6 @@ export function configureIosVersionFunction(): BuildFunction {
return;
}
const targetNamesInput = inputs.target_names.value;
let targetNames: string[];
if (targetNamesInput !== undefined) {
const parsed = z.array(z.string()).safeParse(targetNamesInput);
if (!parsed.success) {
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
'"target_names" input must be an array of strings.',
{ cause: parsed.error }
);
}
targetNames = parsed.data;
} else {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
targetNames = Object.keys(credentials.targetProvisioningProfiles);
}
stepCtx.logger.info('Setting iOS version...');
if (buildNumber) {
stepCtx.logger.info(`Build number: ${buildNumber}`);
@@ -108,7 +87,7 @@ export function configureIosVersionFunction(): BuildFunction {
appVersion,
},
{
targetNames,
targetNames: Object.keys(credentials.targetProvisioningProfiles),
buildConfiguration: resolveBuildConfiguration(
job,
inputs.build_configuration.value as string | undefined
@@ -1,167 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { runEasCliCommand } from '../../utils/easCli';
export function createEasDeployBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'deploy',
name: 'Deploy',
__metricsId: 'eas/deploy',
inputProviders: [
BuildStepInput.createProvider({
id: 'alias',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'prod',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'source_maps',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
// Match eas/export default `output_dir`
BuildStepInput.createProvider({
id: 'export_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'deploy_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'deploy_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_deployment_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_identifier',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_dashboard_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_alias_url',
required: false,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const alias = inputs.alias.value as string | undefined;
const prod = inputs.prod.value as boolean | undefined;
const sourceMaps = inputs.source_maps.value as boolean | undefined;
const exportDir = inputs.export_dir.value as string;
const deployCommand = getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
});
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
try {
const result = await runEasCliCommand({
args: deployCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
const deployJson = result.stdout.toString();
outputs.deploy_json.set(deployJson);
const parsedDeploymentOutput = parseDeploymentOutput({
deployJson,
logger: stepsCtx.logger,
});
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'unknown error';
throw new UserError(
'EAS_DEPLOY_FAILED',
'Deploy command failed.\n\n' +
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
`Original error: ${errorMessage}`,
{ cause: error }
);
}
},
});
}
function getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
}: {
exportDir: string;
alias?: string;
prod?: boolean;
sourceMaps?: boolean;
}): string[] {
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
if (alias) {
deployCommand.push('--alias', alias);
}
if (prod) {
deployCommand.push('--prod');
}
if (sourceMaps) {
deployCommand.push('--source-maps');
}
return deployCommand;
}
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
deploy_url?: string;
deploy_deployment_url?: string;
deploy_identifier?: string;
deploy_dashboard_url?: string;
deploy_alias_url?: string;
} {
try {
const deployObject = JSON.parse(deployJson);
return {
deploy_url:
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
deploy_deployment_url: deployObject.url,
deploy_identifier: deployObject.identifier,
deploy_dashboard_url: deployObject.dashboardUrl,
deploy_alias_url: deployObject.aliases?.[0]?.url,
};
} catch (error: unknown) {
logger.warn(
{ err: error },
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
);
return {};
}
}
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
throw new Error('Eager bundle is not supported for SDK version < 52');
}
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
| string
| undefined;
@@ -1,140 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { resolvePackageManager } from '../../utils/packageManager';
import { runExpoCliCommand } from '../../utils/project';
export function createEasExportBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'export',
name: 'Export',
__metricsId: 'eas/export',
inputProviders: [
BuildStepInput.createProvider({
id: 'output_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'dev',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'minify',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'dump_assetmap',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ssg',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'platform',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'web',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'export_dir',
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const outputDir = inputs.output_dir.value as string;
const dev = inputs.dev.value as boolean | undefined;
const minify = inputs.minify.value as boolean | undefined;
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
const ssg = inputs.ssg.value as boolean | undefined;
const apiOnly = inputs.api_only.value as boolean | undefined;
const platform = inputs.platform.value as string;
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const exportCommand = getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
});
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
try {
await runExpoCliCommand({
packageManager,
args: exportCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
stdio: 'pipe',
},
});
outputs.export_dir.set(outputDir);
} catch (error) {
throw new UserError(
'EXPO_EXPORT_FAILED',
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
},
});
}
function getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
}: {
outputDir: string;
dev?: boolean;
minify?: boolean;
dumpAssetmap?: boolean;
ssg?: boolean;
apiOnly?: boolean;
platform: string;
}): string[] {
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
if (dev) {
exportCommand.push('--dev');
}
if (minify === false) {
exportCommand.push('--no-minify');
}
if (dumpAssetmap) {
exportCommand.push('--dump-assetmap');
}
if (ssg === false) {
exportCommand.push('--no-ssg');
}
if (apiOnly) {
exportCommand.push('--api-only');
}
return exportCommand;
}
@@ -6,7 +6,6 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { templateString } from '@expo/template-file';
import assert from 'assert';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -37,10 +36,6 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -60,9 +55,6 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
export function generateGymfileFromTemplateFunction(): BuildFunction {
@@ -189,7 +181,6 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
ICLOUD_CONTAINER_ENVIRONMENT,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: './build',
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
...(PROFILES ? { PROFILES } : {}),
...(credentials
? {
@@ -27,7 +27,7 @@ export async function installNodeModules(
env: BuildStepEnv
): Promise<void> {
const { logger } = stepCtx;
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
if (packagerRunDir !== stepCtx.workingDirectory) {
@@ -1,51 +0,0 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
export function parseXcactivitylogFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'parse_xcactivitylog',
name: 'Analyze build performance',
__metricsId: 'eas/parse_xcactivitylog',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'derived_data_path',
required: false,
defaultValue: 'ios/build',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'workspace_path',
required: false,
defaultValue: 'ios',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'xclogparser_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, env }) => {
const derivedDataPath = inputs.derived_data_path.value as string;
const workspacePath = inputs.workspace_path.value as string;
const version = inputs.xclogparser_version.value as string | undefined;
await parseAndReportXcactivitylog({
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
},
});
}
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
fn: async (stepCtx, { inputs, env }) => {
const { logger } = stepCtx;
const appleTeamId = inputs.apple_team_id.value as string | undefined;
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
const job = stepCtx.global.staticContext.job;
@@ -1,43 +0,0 @@
import { Metadata } from '@expo/eas-build-job';
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAppConfig } from '../../utils/appConfig';
export function createReadAppConfigBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_app_config',
name: 'Read app config',
__metricsId: 'eas/read_app_config',
outputProviders: [
BuildStepOutput.createProvider({
id: 'app_config',
required: false,
}),
],
fn: async (stepCtx, { env, outputs }) => {
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
stepCtx.logger.info('Using app configuration:');
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
outputs.app_config.set(JSON.stringify(appConfig));
} catch {
// readAppConfig logs underlying failures.
}
},
});
}
@@ -1,26 +0,0 @@
import { BuildFunction, BuildStepOutput } from '@expo/steps';
import { readAndLogPackageJson } from '../../utils/project';
export function createReadPackageJsonBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_package_json',
name: 'Read package.json',
__metricsId: 'eas/read_package_json',
outputProviders: [
BuildStepOutput.createProvider({
id: 'package_json',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
try {
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
outputs.package_json.set(JSON.stringify(packageJson));
} catch (err: unknown) {
stepCtx.logger.error({ err });
}
},
});
}
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_GRADLE_CACHE !== '1') {
if (env.EXPERIMENTAL_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.EAS_GRADLE_CACHE !== '1') {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -1,267 +0,0 @@
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sleepAsync } from '../../utils/retry';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger }, { inputs, env }) => {
const packageVersion = inputs.package_version.value as string | undefined;
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function ensureCloudflaredInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}
async function ensureBunInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}
@@ -1,11 +1,5 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -50,14 +44,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
@@ -267,32 +253,3 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
},
});
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<void> {
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
if (!isAndroidEmulatorHostSupported) {
throw new UserError(
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
'Could not start the Android emulator on this runner.\n\n' +
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
' runs_on: linux-medium-nested-virtualization\n' +
' runs_on: linux-large-nested-virtualization\n\n' +
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
);
}
}
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
try {
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
return true;
} catch {
return false;
}
}
@@ -1,504 +0,0 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
jest.mock('@expo/downloader');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
schema: { name: 'TestProject' },
subSteps: [
{
title: 'Build target ModuleA',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 103.0,
signature: 'CompileC foo.c',
subSteps: [],
},
{
detailStepType: 'other',
signature: 'SwiftCompile normal bar.swift',
duration: 3.0,
startTimestamp: 101.0,
endTimestamp: 104.0,
subSteps: [],
},
],
},
{
title: 'Build target ModuleB',
subSteps: [
{
detailStepType: 'compileAssetsCatalog',
duration: 2.0,
startTimestamp: 200.0,
endTimestamp: 202.0,
signature: 'CompileAssetCatalog',
subSteps: [],
},
],
},
{
title: 'Build target TinyModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 0.1,
startTimestamp: 300.0,
endTimestamp: 300.1,
signature: 'CompileC tiny.c',
subSteps: [],
},
],
},
],
};
// Fixture: empty data
const FIXTURE_EMPTY = {
schema: { name: 'EmptyProject' },
subSteps: [],
};
// Fixture: no compile steps (only non-compile sub-steps)
const FIXTURE_NO_COMPILE = {
schema: { name: 'NoCompileProject' },
subSteps: [
{
title: 'Build target SomeTarget',
subSteps: [
{
detailStepType: 'linkCommand',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 105.0,
signature: 'Ld something',
subSteps: [],
},
],
},
],
};
// Fixture: missing timestamps
const FIXTURE_MISSING_TIMESTAMPS = {
schema: { name: 'MissingTimestamps' },
subSteps: [
{
title: 'Build target PartialModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 3.0,
signature: 'CompileC partial.c',
subSteps: [],
// no startTimestamp/endTimestamp
},
],
},
],
};
// Fixture: nested compile steps (compile step inside non-compile step)
const FIXTURE_NESTED = {
schema: { name: 'NestedProject' },
subSteps: [
{
title: 'Build target NestedModule',
subSteps: [
{
detailStepType: 'scriptExecution',
duration: 10.0,
startTimestamp: 100.0,
endTimestamp: 110.0,
signature: 'PhaseScriptExecution',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 2.0,
startTimestamp: 101.0,
endTimestamp: 103.0,
signature: 'CompileC nested.c',
subSteps: [],
},
],
},
],
},
],
};
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
}: {
tempDir?: string;
jsonOutput?: string;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
reportPath: path.join(tempDir, 'xcactivitylog.json'),
tempDir,
};
}
describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
schema: { name: 'BadProject' },
subSteps: [{ title: 123, subSteps: [] }],
}),
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('swallows cleanup failures without emitting additional logs', async () => {
const logger = createMockLogger();
mockFilesystem();
jest.spyOn(fs, 'rm').mockImplementation(async () => {
throw new Error('cleanup failed');
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
describe('isCompileStep', () => {
it('identifies cCompilation', () => {
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
});
it('identifies compileAssetsCatalog', () => {
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
});
it('identifies compileStoryboard', () => {
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
});
it('identifies SwiftCompile by signature prefix', () => {
expect(
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
).toBe(true);
});
it('identifies SwiftGeneratePch by signature prefix', () => {
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
true
);
});
it('rejects non-compile steps', () => {
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
});
it('handles missing fields gracefully', () => {
expect(isCompileStep({})).toBe(false);
});
});
describe('collectTopLevelCompileSteps', () => {
it('collects direct compile children', () => {
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(2);
});
it('finds nested compile steps through non-compile parents', () => {
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(1);
expect(result[0].detailStepType).toBe('cCompilation');
});
it('returns empty array when no compile steps', () => {
const target = FIXTURE_NO_COMPILE.subSteps[0];
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(0);
});
});
describe('buildTargetMetrics', () => {
it('returns metrics sorted by taskSeconds descending', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
expect(results.length).toBeGreaterThan(0);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
}
});
it('computes correct taskSeconds for ModuleA', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
});
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
});
it('filters out modules below minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
});
it('allows custom minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeDefined();
});
it('returns empty results for empty data', () => {
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
expect(results).toHaveLength(0);
expect(totalTaskSeconds).toBe(0);
});
it('handles missing timestamps gracefully', () => {
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
const mod = results.find(r => r.moduleName === 'PartialModule');
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(3.0);
expect(mod!.wallSpan).toBe(0); // no valid intervals
});
});
describe('formatReport', () => {
it('produces table with header and module rows', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('ModuleA');
expect(report).toContain('ModuleB');
expect(report).toContain('TOTAL');
expect(report).toContain('% Task');
});
it('does not include modules below threshold', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).not.toContain('TinyModule');
});
it('handles empty data without crashing', () => {
const report = formatReport(FIXTURE_EMPTY);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('TOTAL');
});
});
@@ -1,418 +0,0 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
}): Promise<void> {
let tempDir: string | undefined;
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
});
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
}
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
const proxiedUrl = getProxiedDownloadUrl({ directUrl, proxyBaseUrl });
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
{ err: proxiedDownloadResult.reason },
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
await cleanupXclogparserArtifacts({ tempDir, zipPath });
} else {
return proxiedDownloadResult.value;
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
return binaryPath;
}
async function cleanupXclogparserArtifacts({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<void> {
await asyncResult(fs.rm(zipPath, { force: true }));
await asyncResult(fs.rm(path.join(tempDir, 'xclogparser'), { force: true }));
}
async function runXclogparser({
binaryPath,
derivedDataPath,
workspacePath,
outputDir,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
await spawn(
binaryPath,
[
'parse',
'--derived_data',
derivedDataPath,
'--workspace',
workspacePath,
'--reporter',
'json',
'--output',
outputPath,
],
{ stdio: 'pipe' }
);
return outputPath;
}
const XcactivitylogStepSchemaZ = z.object({
title: z.string().optional(),
detailStepType: z.string().optional(),
signature: z.string().optional(),
duration: z.number().optional(),
startTimestamp: z.number().optional(),
endTimestamp: z.number().optional(),
get subSteps() {
return z.array(XcactivitylogStepSchemaZ).optional();
},
});
const XcactivitylogDataSchemaZ = z.object({
schema: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
subSteps: z.array(XcactivitylogStepSchemaZ).optional(),
});
type XcactivitylogStep = z.infer<typeof XcactivitylogStepSchemaZ>;
type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
interface TargetMetric {
moduleName: string;
taskSeconds: number;
wallSpan: number;
activeWallTime: number;
}
interface Interval {
start: number;
end: number;
}
const COMPILE_DETAIL_TYPES = new Set(['cCompilation', 'compileAssetsCatalog', 'compileStoryboard']);
const COMPILE_SIGNATURE_PREFIXES = ['SwiftCompile ', 'SwiftGeneratePch '];
function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
return true;
}
return COMPILE_SIGNATURE_PREFIXES.some(prefix => signature.startsWith(prefix));
}
export function collectTopLevelCompileSteps(
step: XcactivitylogStep,
results: XcactivitylogStep[] = []
): XcactivitylogStep[] {
for (const child of step.subSteps ?? []) {
if (isCompileStep(child)) {
results.push(child);
continue;
}
collectTopLevelCompileSteps(child, results);
}
return results;
}
function intervalFromStep(step: XcactivitylogStep): Interval | null {
if (typeof step.startTimestamp !== 'number' || typeof step.endTimestamp !== 'number') {
return null;
}
return { start: step.startTimestamp, end: step.endTimestamp };
}
function computeActiveWallTime(intervals: Interval[]): number {
if (intervals.length === 0) {
return 0;
}
const sorted = [...intervals].sort((a, b) => a.start - b.start);
let activeWallTime = 0;
let currentStart = sorted[0].start;
let currentEnd = sorted[0].end;
for (const interval of sorted.slice(1)) {
if (interval.start <= currentEnd) {
currentEnd = Math.max(currentEnd, interval.end);
continue;
}
activeWallTime += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
activeWallTime += currentEnd - currentStart;
return activeWallTime;
}
export function buildTargetMetrics(
data: XcactivitylogData,
{ minTaskSeconds = 0.5 }: { minTaskSeconds?: number } = {}
): { results: TargetMetric[]; totalTaskSeconds: number } {
const results: TargetMetric[] = [];
for (const step of data.subSteps ?? []) {
const title = step.title ?? '';
if (!title.startsWith('Build target ')) {
continue;
}
const moduleName = title.replace('Build target ', '');
const compileSteps = collectTopLevelCompileSteps(step);
const taskSeconds = compileSteps.reduce((sum, s) => sum + (s.duration ?? 0), 0);
if (taskSeconds < minTaskSeconds) {
continue;
}
const intervals = compileSteps.map(intervalFromStep).filter((i): i is Interval => i !== null);
const minStart = intervals.length > 0 ? Math.min(...intervals.map(i => i.start)) : 0;
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map(i => i.end)) : 0;
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
const activeWallTime = computeActiveWallTime(intervals);
results.push({ moduleName, taskSeconds, wallSpan, activeWallTime });
}
results.sort((a, b) => {
if (b.taskSeconds !== a.taskSeconds) {
return b.taskSeconds - a.taskSeconds;
}
if (b.wallSpan !== a.wallSpan) {
return b.wallSpan - a.wallSpan;
}
return a.moduleName.localeCompare(b.moduleName);
});
const totalTaskSeconds = results.reduce((sum, r) => sum + r.taskSeconds, 0);
return { results, totalTaskSeconds };
}
function formatSeconds(value: number): string {
return `${value.toFixed(1)}s`;
}
export function formatReport(data: XcactivitylogData): string {
const { results, totalTaskSeconds } = buildTargetMetrics(data);
const nameWidth = Math.max(6, ...results.map(r => r.moduleName.length)) + 2;
const taskWidth = 10;
const pctWidth = 8;
const wallWidth = 10;
const barMaxWidth = 20;
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const lines: string[] = [];
lines.push('');
lines.push('Xcode Build — Compile Metrics by Module');
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
lines.push('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Task'.padStart(taskWidth) +
' │ ' +
'% Task'.padStart(pctWidth) +
' │ ' +
'Wall'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const result of results) {
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
lines.push(
'│ ' +
result.moduleName.padEnd(nameWidth) +
' │ ' +
formatSeconds(result.taskSeconds).padStart(taskWidth) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.wallSpan).padStart(wallWidth) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -20,9 +20,5 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -13,7 +13,4 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
@@ -1,139 +0,0 @@
import { EasCliNpmTags } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
import { isAtLeastNpm7Async } from '../packageManager';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
isAtLeastNpm7Async: jest.fn(async () => true),
}));
describe(resolveEasCommandPrefixAndEnvAsync, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('resolves easd in development when easd --help succeeds', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 0 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'easd',
args: [],
extraEnv: {},
});
expect(spawn).toHaveBeenCalledWith(
'easd',
['--help'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
});
it('resolves staging tag in development when easd --help fails', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag in development when easd probe throws', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
process.env.ENVIRONMENT = 'staging';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
it('resolves production tag by default', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
expect(spawn).not.toHaveBeenCalled();
});
it('omits -y when npm is older than v7', async () => {
jest.mocked(isAtLeastNpm7Async).mockResolvedValueOnce(false);
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
});
});
describe(runEasCliCommand, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('merges caller env with resolved extra env', async () => {
process.env.ENVIRONMENT = 'staging';
await runEasCliCommand({
args: ['deploy', '--json'],
options: {
cwd: '/tmp/project',
env: { FOO: 'bar' },
},
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
expect.objectContaining({
cwd: '/tmp/project',
env: expect.objectContaining({
FOO: 'bar',
EXPO_STAGING: '1',
}),
})
);
});
});
@@ -1,4 +1,3 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
@@ -6,7 +5,6 @@ import path from 'path';
import semver from 'semver';
import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
@@ -25,23 +23,23 @@ describe(resolvePackageManager, () => {
});
it('returns yarn when no lockfiles exist', async () => {
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns npm when only package-json.lock exist', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
expect(resolvePackageManager(rootDir)).toBe('npm');
});
it('returns yarn when only yarn.lock exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns yarn when both lockfiles exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns npm within a monorepo', async () => {
@@ -57,7 +55,7 @@ describe(resolvePackageManager, () => {
name: '@monorepo/expo-app',
});
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
expect(resolvePackageManager(nestedDir)).toBe('npm');
});
it('returns yarn with an invalid monorepo', async () => {
@@ -69,51 +67,7 @@ describe(resolvePackageManager, () => {
await fs.mkdirp(nestedDir);
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
});
it('returns yarn when no lockfile and env var is an empty string', () => {
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: '' },
})
).toBe(PackageManager.YARN);
});
it('returns bun from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bun' } })).toBe(
PackageManager.BUN
);
});
it('returns pnpm from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'pnpm' } })).toBe(
PackageManager.PNPM
);
});
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' }, // invalid, but should not even be read
})
).toBe(PackageManager.NPM);
});
it('throws a UserError on unsupported EAS_FALLBACK_PACKAGE_MANAGER value', () => {
expect(() =>
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } })
).toThrow(errors.UserError);
try {
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } });
} catch (e) {
expect(e).toBeInstanceOf(errors.UserError);
const error = e as errors.UserError;
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
expect(error.message).toContain('bunn');
expect(error.message).toContain('yarn, npm, pnpm, bun');
}
expect(resolvePackageManager(nestedDir)).toBe('yarn');
});
});
@@ -9,12 +9,7 @@ import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import {
isUsingModernYarnVersion,
readAndLogPackageJson,
readEasJsonContents,
runExpoCliCommand,
} from '../project';
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
@@ -153,23 +148,3 @@ describe(readEasJsonContents, () => {
expect(readEasJsonContents(projectDir)).toBe(contents);
});
});
describe(readAndLogPackageJson, () => {
beforeEach(() => {
vol.reset();
});
it('reads package.json, logs it, and returns parsed contents', async () => {
const projectDir = '/project';
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = { name: 'app', version: '1.0.0' };
const logger = { info: jest.fn() };
await fs.mkdirp(projectDir);
await fs.writeJson(packageJsonPath, packageJson);
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
});
});
-61
View File
@@ -1,61 +0,0 @@
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
import { isAtLeastNpm7Async } from './packageManager';
async function probeEasdAsync(): Promise<boolean> {
try {
const result = await spawn('easd', ['--help'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return result.status === 0;
} catch {
return false;
}
}
export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
if (await probeEasdAsync()) {
return { cmd: 'easd', args: [], extraEnv: {} };
}
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
export async function runEasCliCommand({
args,
options,
}: {
args: string[];
options: SpawnOptions;
}): Promise<SpawnResult> {
const { logger, ...spawnOptions } = options;
const { cmd, args: commandPrefixArgs, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
return await spawn(cmd, [...commandPrefixArgs, ...args], {
...spawnOptions,
logger,
env: { ...spawnOptions.env, ...extraEnv },
});
}
@@ -1,7 +1,5 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
import { z } from 'zod';
@@ -13,10 +11,7 @@ export enum PackageManager {
BUN = 'bun',
}
export function resolvePackageManager(
directory: string,
{ env }: { env: BuildStepEnv }
): PackageManager {
export function resolvePackageManager(directory: string): PackageManager {
try {
const manager = PackageManagerUtils.resolvePackageManager(directory);
if (manager === 'npm') {
@@ -25,24 +20,12 @@ export function resolvePackageManager(
return PackageManager.PNPM;
} else if (manager === 'bun') {
return PackageManager.BUN;
} else if (manager === 'yarn') {
} else {
return PackageManager.YARN;
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
} catch {
return PackageManager.YARN;
}
return PackageManager.YARN;
}
/**
-10
View File
@@ -78,16 +78,6 @@ export function readPackageJson(projectDir: string): any {
}
}
export function readAndLogPackageJson(
logger: { info: (message: string) => void },
projectDir: string
): any {
logger.info('Using package.json:');
const packageJson = readPackageJson(projectDir);
logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
}
export function readEasJsonContents(projectDir: string): string {
const easJsonPath = path.join(projectDir, 'eas.json');
if (!fs.pathExistsSync(easJsonPath)) {
-1
View File
@@ -11,7 +11,6 @@
"composite": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["jest", "node"],
"plugins": [
{
"name": "gql.tada/ts-plugin",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.8.0",
"version": "18.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.8.0"
"@expo/steps": "18.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.8.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -56,110 +56,6 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
@@ -188,232 +84,6 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context with app_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'ready_for_review',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect app_version state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with external_beta', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'beta_approved',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect external_beta state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with beta_feedback', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'screenshot',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects app_store_connect beta_feedback without url', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'crash',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect beta_feedback type', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'invalid-type',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
@@ -403,7 +403,6 @@ describe('Ios.JobSchema', () => {
);
expect(value).not.toMatchObject(managedJob);
});
test('validates channel', () => {
const managedJob = {
secrets: {
+13 -68
View File
@@ -226,72 +226,6 @@ const GitHubContextZ = z.object({
.optional(),
});
const AppStoreConnectContextZ = z.looseObject({
app: z.looseObject({
id: z.string(),
}),
app_version: z
.looseObject({
id: z.string(),
state: z.enum([
'accepted',
'developer_rejected',
'in_review',
'invalid_binary',
'metadata_rejected',
'pending_apple_release',
'pending_developer_release',
'prepare_for_submission',
'processing_for_distribution',
'ready_for_distribution',
'ready_for_review',
'rejected',
'replaced_with_new_version',
'waiting_for_export_compliance',
'waiting_for_review',
]),
})
.optional(),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
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(),
@@ -325,8 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
app_store_connect: AppStoreConnectContextZ.optional(),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
})
.optional(),
})
.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
-3
View File
@@ -41,7 +41,6 @@ export enum BuildPhase {
INSTALL_PODS = 'INSTALL_PODS',
CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT',
RUN_FASTLANE = 'RUN_FASTLANE',
PARSE_XCACTIVITYLOG = 'PARSE_XCACTIVITYLOG',
// HOOKS
PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK',
@@ -109,7 +108,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'Install pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project',
[BuildPhase.RUN_FASTLANE]: 'Run fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'Analyze build performance',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook',
@@ -174,7 +172,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'install-pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project',
[BuildPhase.RUN_FASTLANE]: 'run-fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'parse-xcactivitylog',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook',
+91 -149
View File
@@ -84,7 +84,6 @@ eas --help COMMAND
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
* [`eas channel:delete [NAME]`](#eas-channeldelete-name)
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
* [`eas channel:insights`](#eas-channelinsights)
* [`eas channel:list`](#eas-channellist)
* [`eas channel:pause [NAME]`](#eas-channelpause-name)
* [`eas channel:resume [NAME]`](#eas-channelresume-name)
@@ -134,7 +133,6 @@ eas --help COMMAND
* [`eas update:configure`](#eas-updateconfigure)
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
* [`eas update:list`](#eas-updatelist)
* [`eas update:republish`](#eas-updaterepublish)
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
@@ -180,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -197,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -219,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.8.1/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -236,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -250,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.8.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -302,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.8.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -323,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.8.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -343,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.8.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -363,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.8.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -386,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.8.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -424,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.8.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,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.8.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -462,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.8.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -482,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.8.1/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -490,7 +488,7 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
```
USAGE
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
@@ -498,13 +496,12 @@ FLAGS
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
--skip-bundler Install and run the development build without starting the bundler server.
DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -525,7 +522,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -563,7 +560,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -603,7 +600,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -633,7 +630,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -659,7 +656,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -711,7 +708,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -729,7 +726,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -747,7 +744,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -764,7 +761,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -785,7 +782,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -806,7 +803,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -828,31 +825,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
display adoption, crash, and unique-user insights for a channel + runtime version
```
USAGE
$ eas channel:insights --channel <value> --runtime-version <value> [--days <value> | --start <value> | --end
<value>] [--json] [--non-interactive]
FLAGS
--channel=<value> (required) Name of the channel.
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
--end=<value> End of insights time range (ISO date).
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--runtime-version=<value> (required) Runtime version to query insights for.
--start=<value> Start of insights time range (ISO date).
DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -872,7 +845,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -894,7 +867,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -916,7 +889,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -951,7 +924,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -974,7 +947,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -995,7 +968,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1012,7 +985,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1030,7 +1003,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1059,7 +1032,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1084,7 +1057,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1105,7 +1078,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1126,7 +1099,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1163,7 +1136,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1183,7 +1156,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1204,7 +1177,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1225,7 +1198,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1239,7 +1212,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1253,7 +1226,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1285,7 +1258,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1311,7 +1284,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1333,7 +1306,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1361,7 +1334,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1389,7 +1362,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1412,7 +1385,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1434,7 +1407,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1468,7 +1441,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1512,7 +1485,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1543,7 +1516,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1652,7 +1625,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1670,7 +1643,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1688,7 +1661,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1740,7 +1713,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1754,7 +1727,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1777,7 +1750,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1801,7 +1774,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1819,7 +1792,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1852,7 +1825,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1895,7 +1868,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1916,7 +1889,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1937,7 +1910,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1962,34 +1935,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
display launch, crash, unique-user, and size insights for an update group
```
USAGE
$ eas update:insights GROUPID [--platform ios|android] [--days <value> | --start <value> | --end <value>] [--json]
[--non-interactive]
ARGUMENTS
GROUPID The ID of an update group.
FLAGS
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
--end=<value> End of insights time range (ISO date).
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--platform=<option> Filter to a single platform.
<options: ios|android>
--start=<value> Start of insights time range (ISO date).
DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -2016,7 +1962,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2054,7 +2000,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2082,7 +2028,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2112,7 +2058,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2133,7 +2079,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2141,23 +2087,19 @@ update group details
```
USAGE
$ eas update:view GROUPID [--insights] [--days <value> | --start <value> | --end <value>] [--json]
$ eas update:view GROUPID [--json]
ARGUMENTS
GROUPID The ID of an update group.
FLAGS
--days=<value> Show insights from the last N days (default 7). Only used with --insights.
--end=<value> End of insights time range (ISO date). Only used with --insights.
--insights Also show insights (launches, crash rate, unique users, payload size) for the update group.
--json Enable JSON output, non-JSON messages will be printed to stderr.
--start=<value> Start of insights time range (ISO date). Only used with --insights.
--json Enable JSON output, non-JSON messages will be printed to stderr.
DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2178,7 +2120,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2200,7 +2142,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2220,7 +2162,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2239,7 +2181,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2262,7 +2204,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2279,7 +2221,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2400,7 +2342,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2420,7 +2362,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2444,7 +2386,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2484,7 +2426,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2506,7 +2448,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2534,7 +2476,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2554,7 +2496,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2576,5 +2518,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+531 -5374
View File
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.8.1",
"version": "18.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/eas-build-job": "18.5.0",
"@expo/eas-json": "18.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.0",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.8.0",
"@expo/steps": "18.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -204,9 +204,6 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
+116
View File
@@ -800,6 +800,118 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"dataUsage": {
"$ref": "#/definitions/apple/AppleDataUsage",
"description": "App Privacy Nutrition Labels. Declares what data the app collects, how it is used, and how it is linked to the user. Required for new app submissions since 2021."
}
}
},
"AppleDataUsage": {
"type": "object",
"additionalProperties": false,
"description": "Privacy Nutrition Labels — declarative declaration of data the app collects. When `dataNotCollected` is true, no data is collected and `categories` must be empty.",
"properties": {
"dataNotCollected": {
"type": "boolean",
"description": "Whether the app collects no data at all. Mirrors Apple's \"Data Not Collected\" toggle. When true, `categories` must be empty."
},
"categories": {
"type": "array",
"description": "Per-category data usage declarations.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryEntry"
}
}
}
},
"AppleDataUsageCategoryEntry": {
"type": "object",
"additionalProperties": false,
"required": ["category"],
"description": "A single Privacy Nutrition Labels declaration: pairs a data category with the purposes it is used for and the data protection / linkage classification.",
"properties": {
"category": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryId",
"description": "Apple data category."
},
"purposes": {
"type": "array",
"description": "Purposes the category is used for. At least one entry is required when the category is collected.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsagePurposeId"
}
},
"protections": {
"type": "array",
"description": "Data protection / linkage classification(s). A single category can be both linked to the user and used for tracking.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageDataProtectionId"
}
}
}
},
"AppleDataUsageCategoryId": {
"enum": [
"ADVERTISING_DATA",
"AUDIO",
"BROWSING_HISTORY",
"COARSE_LOCATION",
"CONTACTS",
"CRASH_DATA",
"CREDIT_AND_FRAUD",
"CUSTOMER_SUPPORT",
"DEVICE_ID",
"EMAIL_ADDRESS",
"EMAILS_OR_TEXT_MESSAGES",
"ENVIRONMENTAL_SCANNING",
"FITNESS",
"GAMEPLAY_CONTENT",
"HANDS",
"HEAD_MOVEMENT",
"HEALTH",
"NAME",
"OTHER_CONTACT_INFO",
"OTHER_DATA",
"OTHER_DIAGNOSTIC_DATA",
"OTHER_FINANCIAL_INFO",
"OTHER_USAGE_DATA",
"OTHER_USER_CONTENT",
"PAYMENT_INFORMATION",
"PERFORMANCE_DATA",
"PHONE_NUMBER",
"PHOTOS_OR_VIDEOS",
"PHYSICAL_ADDRESS",
"PRECISE_LOCATION",
"PRODUCT_INTERACTION",
"PURCHASE_HISTORY",
"SEARCH_HISTORY",
"SENSITIVE_INFO",
"USER_ID"
]
},
"AppleDataUsagePurposeId": {
"enum": [
"THIRD_PARTY_ADVERTISING",
"DEVELOPERS_ADVERTISING",
"ANALYTICS",
"PRODUCT_PERSONALIZATION",
"APP_FUNCTIONALITY",
"OTHER_PURPOSES"
]
},
"AppleDataUsageDataProtectionId": {
"enum": [
"DATA_USED_TO_TRACK_YOU",
"DATA_LINKED_TO_YOU",
"DATA_NOT_LINKED_TO_YOU",
"DATA_NOT_COLLECTED"
]
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1034,6 +1146,10 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
-18
View File
@@ -64,24 +64,6 @@ export function getWorkflowRunUrl(
).toString();
}
/**
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
* or the feature-specific dashboard) that gives users more context. Use this only for internal
* tooling where no richer URL exists.
*/
export function getBareJobRunUrl(
accountName: string,
projectName: string,
jobRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/job-runs/${encodeURIComponent(jobRunId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
@@ -1,142 +0,0 @@
import { ChannelRuntimeInsights } from '../../../graphql/queries/ChannelInsightsQuery';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../formatInsights';
function makeInsights(
overrides: {
embeddedUpdateTotalUniqueUsers?: number;
mostPopularUpdates?: {
id: string;
group: string;
message: string | null;
runtimeVersion: string;
platform: string;
totalUniqueUsers: number;
}[];
} = {}
): ChannelRuntimeInsights {
return {
__typename: 'UpdateChannelRuntimeInsights',
id: 'rti-id',
embeddedUpdateTotalUniqueUsers: overrides.embeddedUpdateTotalUniqueUsers ?? 250,
mostPopularUpdates: (
overrides.mostPopularUpdates ?? [
{
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
totalUniqueUsers: 800,
},
{
id: 'u2',
group: 'group-b',
message: 'second',
runtimeVersion: '1.0.0',
platform: 'android',
totalUniqueUsers: 200,
},
]
).map(u => ({
__typename: 'Update' as const,
id: u.id,
group: u.group,
message: u.message,
runtimeVersion: u.runtimeVersion,
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
id: `${u.id}-insights`,
totalUniqueUsers: u.totalUniqueUsers,
},
})),
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'd1', label: 'iOS', data: [100, 200] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'cd1', label: 'Launches', data: [10, 20] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint', id: 'launches', label: 'Launches', data: 1234 },
],
},
};
}
const TIMESPAN = {
startTime: '2026-04-09T00:00:00.000Z',
endTime: '2026-04-16T00:00:00.000Z',
daysBack: 7,
};
describe(toChannelInsightsSummary, () => {
it('ranks most popular updates by their order in the response', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.mostPopularUpdates.map(u => u.rank)).toEqual([1, 2]);
expect(summary.mostPopularUpdates[0].groupId).toBe('group-a');
});
it('sums OTA totalUniqueUsers across most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.otaTotalUniqueUsers).toBe(1000);
});
it('passes through embedded update unique users', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ embeddedUpdateTotalUniqueUsers: 42 }),
TIMESPAN
);
expect(summary.embeddedUpdateTotalUniqueUsers).toBe(42);
});
});
describe(buildChannelInsightsJson, () => {
it('returns a serializable object', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const json = buildChannelInsightsJson(summary) as any;
expect(json.channel).toBe('production');
expect(json.runtimeVersion).toBe('1.0.0');
expect(json.timespan.daysBack).toBe(7);
expect(json.mostPopularUpdates).toHaveLength(2);
expect(json.cumulativeMetricsAtLastTimestamp[0].label).toBe('Launches');
});
});
describe(buildChannelInsightsTable, () => {
it('renders channel, runtime, embedded users and most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/Channel/);
expect(table).toMatch(/production/);
expect(table).toMatch(/1\.0\.0/);
expect(table).toMatch(/Embedded update users/);
expect(table).toMatch(/group-a/);
expect(table).toMatch(/Most popular updates/);
});
it('shows a friendly message when there are no popular updates', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ mostPopularUpdates: [] }),
TIMESPAN
);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/No update launches recorded/);
});
});
@@ -1,164 +0,0 @@
import chalk from 'chalk';
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
import { formatTimespan } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
export interface ChannelInsightsTimespan {
startTime: string;
endTime: string;
daysBack?: number;
}
export interface ChannelMostPopularUpdate {
rank: number;
groupId: string;
message: string | null;
platform: string;
totalUniqueUsers: number;
}
export interface ChannelInsightsSummary {
channelName: string;
runtimeVersion: string;
startTime: string;
endTime: string;
daysBack?: number;
embeddedUpdateTotalUniqueUsers: number;
otaTotalUniqueUsers: number;
mostPopularUpdates: ChannelMostPopularUpdate[];
cumulativeMetricsAtLastTimestamp: { id: string; label: string; data: number }[];
uniqueUsersOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
cumulativeMetricsOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
}
export function toChannelInsightsSummary(
channelName: string,
runtimeVersion: string,
insights: ChannelRuntimeInsights,
timespan: ChannelInsightsTimespan
): ChannelInsightsSummary {
const mostPopular: ChannelMostPopularUpdate[] = insights.mostPopularUpdates.map((u, i) => ({
rank: i + 1,
groupId: u.group,
message: u.message ?? null,
platform: u.platform,
totalUniqueUsers: u.insights.totalUniqueUsers,
}));
const otaTotalUniqueUsers = mostPopular.reduce((sum, u) => sum + u.totalUniqueUsers, 0);
return {
channelName,
runtimeVersion,
startTime: timespan.startTime,
endTime: timespan.endTime,
daysBack: timespan.daysBack,
embeddedUpdateTotalUniqueUsers: insights.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers,
mostPopularUpdates: mostPopular,
cumulativeMetricsAtLastTimestamp: insights.cumulativeMetricsOverTime.metricsAtLastTimestamp.map(
m => ({ id: m.id, label: m.label, data: m.data })
),
uniqueUsersOverTime: {
labels: insights.uniqueUsersOverTime.data.labels,
datasets: insights.uniqueUsersOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
cumulativeMetricsOverTime: {
labels: insights.cumulativeMetricsOverTime.data.labels,
datasets: insights.cumulativeMetricsOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
};
}
export function buildChannelInsightsJson(summary: ChannelInsightsSummary): object {
return {
channel: summary.channelName,
runtimeVersion: summary.runtimeVersion,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
mostPopularUpdates: summary.mostPopularUpdates,
cumulativeMetricsAtLastTimestamp: summary.cumulativeMetricsAtLastTimestamp,
uniqueUsersOverTime: summary.uniqueUsersOverTime,
cumulativeMetricsOverTime: summary.cumulativeMetricsOverTime,
};
}
export function buildChannelInsightsTable(summary: ChannelInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Channel insights:'));
sections.push(
formatFields([
{ label: 'Channel', value: summary.channelName },
{ label: 'Runtime version', value: summary.runtimeVersion },
{ label: 'Time range', value: formatTimespan(summary) },
{
label: 'Embedded update users',
value: summary.embeddedUpdateTotalUniqueUsers.toLocaleString(),
},
{ label: 'OTA update users', value: summary.otaTotalUniqueUsers.toLocaleString() },
])
);
if (summary.cumulativeMetricsAtLastTimestamp.length > 0) {
sections.push('');
sections.push(chalk.bold('Cumulative metrics at last timestamp:'));
sections.push(
formatFields(
summary.cumulativeMetricsAtLastTimestamp.map(m => ({
label: m.label,
value: m.data.toLocaleString(),
}))
)
);
}
if (summary.mostPopularUpdates.length > 0) {
sections.push('');
sections.push(chalk.bold(`Most popular updates${formatTrailingTimespan(summary)}:`));
sections.push(renderMostPopularTable(summary.mostPopularUpdates));
} else {
sections.push('');
sections.push(chalk.yellow('No update launches recorded for this channel and runtime.'));
}
return sections.join('\n');
}
function formatTrailingTimespan(summary: ChannelInsightsSummary): string {
return summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
}
function renderMostPopularTable(rows: ChannelMostPopularUpdate[]): string {
return renderTextTable(
['#', 'Group ID', 'Platform', 'Unique users', 'Message'],
rows.map(r => [
String(r.rank),
r.groupId,
r.platform,
r.totalUniqueUsers.toLocaleString(),
r.message ?? '',
])
);
}
@@ -35,16 +35,12 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
description?: string;
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
+2 -10
View File
@@ -47,10 +47,6 @@ export default class BuildDev extends EasCommand {
description: 'Skip build if no successful build with matching fingerprint is found.',
default: false,
}),
'skip-bundler': Flags.boolean({
description: 'Install and run the development build without starting the bundler server.',
default: false,
}),
};
static override contextDefinition = {
@@ -133,9 +129,7 @@ export default class BuildDev extends EasCommand {
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build);
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
await this.startDevServerAsync({ projectDir, platform });
return;
} else {
Log.warn('Artifacts for this build expired.');
@@ -188,9 +182,7 @@ export default class BuildDev extends EasCommand {
downloadSimBuildAutoConfirm: true,
envOverride: env,
});
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
await this.startDevServerAsync({ projectDir, platform });
}
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
@@ -1,118 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ChannelInsightsQuery } from '../../../graphql/queries/ChannelInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelInsights from '../insights';
jest.mock('../../../graphql/queries/ChannelInsightsQuery', () => ({
ChannelInsightsQuery: { viewChannelRuntimeInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewChannelRuntimeInsightsAsync = jest.mocked(
ChannelInsightsQuery.viewChannelRuntimeInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const insightsResponse = {
__typename: 'UpdateChannelRuntimeInsights' as const,
id: 'rti',
embeddedUpdateTotalUniqueUsers: 50,
mostPopularUpdates: [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
],
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'd1', label: 'iOS', data: [50] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'cd1', label: 'Launches', data: [42] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint' as const, id: 'launches', label: 'Launches', data: 42 },
],
},
};
describe(ChannelInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'project-1';
beforeEach(() => {
jest.clearAllMocks();
mockViewChannelRuntimeInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): ChannelInsights {
const command = new ChannelInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('passes appId, channel, runtimeVersion and default 7-day timespan to the query', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--channel', 'production', '--runtime-version', '1.0.0']);
await command.runAsync();
expect(mockViewChannelRuntimeInsightsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
channelName: 'production',
runtimeVersion: '1.0.0',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
});
jest.useRealTimers();
});
it('emits JSON when --json is passed', async () => {
const command = createCommand([
'--channel',
'production',
'--runtime-version',
'1.0.0',
'--json',
'--non-interactive',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
channel: 'production',
runtimeVersion: '1.0.0',
embeddedUpdateTotalUniqueUsers: 50,
})
);
});
it('requires --channel and --runtime-version', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
});
});
@@ -1,88 +0,0 @@
import { Flags } from '@oclif/core';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../../channel/insights/formatInsights';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelInsights extends EasCommand {
static override description =
'display adoption, crash, and unique-user insights for a channel + runtime version';
static override flags = {
channel: Flags.string({
description: 'Name of the channel.',
required: true,
}),
'runtime-version': Flags.string({
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const insights = await ChannelInsightsQuery.viewChannelRuntimeInsightsAsync(graphqlClient, {
appId: projectId,
channelName: flags.channel,
runtimeVersion: flags['runtime-version'],
startTime,
endTime,
});
const summary = toChannelInsightsSummary(flags.channel, flags['runtime-version'], insights, {
startTime,
endTime,
daysBack,
});
if (json) {
printJsonOnlyOutput(buildChannelInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildChannelInsightsTable(summary));
}
}
}
+2 -16
View File
@@ -362,22 +362,8 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
let uploadError: unknown;
try {
await uploadAssetsAsync(assetFiles, deployResult);
} catch (error) {
uploadError = error;
}
try {
await finalizeDeployAsync(deployResult);
} catch (finalizeError) {
if (!uploadError) {
throw finalizeError;
}
}
if (uploadError) {
throw uploadError;
}
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -1,280 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObserveEventsOrderByDirection,
AppObserveEventsOrderByField,
AppObservePlatform,
} from '../../../graphql/generated';
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
jest.mock('../../../observe/fetchEvents', () => {
const actual = jest.requireActual('../../../observe/fetchEvents');
return {
...actual,
fetchObserveEventsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveEvents, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveEvents {
const command = new ObserveEvents(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--days', '7']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['tti', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['tti']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('passes --sort flag through to fetchObserveEventsAsync', async () => {
const command = createCommand(['tti', '--sort', 'slowest']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.orderBy).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('throws in non-interactive mode when no metric is provided', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(
'metric argument is required in non-interactive mode'
);
});
});
describe(resolveOrderBy, () => {
it('resolves lowercase "slowest" to MetricValue DESC', () => {
expect(resolveOrderBy('slowest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('resolves lowercase "fastest" to MetricValue ASC', () => {
expect(resolveOrderBy('fastest')).toEqual({
field: AppObserveEventsOrderByField.MetricValue,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
it('resolves lowercase "newest" to Timestamp DESC', () => {
expect(resolveOrderBy('newest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Desc,
});
});
it('resolves lowercase "oldest" to Timestamp ASC', () => {
expect(resolveOrderBy('oldest')).toEqual({
field: AppObserveEventsOrderByField.Timestamp,
direction: AppObserveEventsOrderByDirection.Asc,
});
});
});
@@ -1,224 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetrics, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue({
metricsMap: new Map(),
buildNumbersMap: new Map(),
updateIdsMap: new Map(),
totalEventCounts: new Map(),
});
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'median', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median'],
expect.objectContaining({ daysBack: 60 })
);
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7']);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount'],
expect.objectContaining({ daysBack: 60 })
);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'average',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.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();
});
});
@@ -1,204 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveVersionsAsync } from '../../../observe/fetchVersions';
import {
buildObserveVersionsJson,
buildObserveVersionsTable,
} from '../../../observe/formatVersions';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveVersions from '../versions';
jest.mock('../../../observe/fetchVersions');
jest.mock('../../../observe/formatVersions', () => ({
buildObserveVersionsTable: jest.fn().mockReturnValue('table'),
buildObserveVersionsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveVersionsAsync = jest.mocked(fetchObserveVersionsAsync);
const mockBuildObserveVersionsTable = jest.mocked(buildObserveVersionsTable);
const mockBuildObserveVersionsJson = jest.mocked(buildObserveVersionsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveVersions, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveVersionsAsync.mockResolvedValue([]);
});
function createCommand(argv: string[]): ObserveVersions {
const command = new ObserveVersions(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches versions with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveVersionsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveVersionsAsync.mock.calls[0][2];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses --days to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days', '7']);
await command.runAsync();
const startTime = mockFetchObserveVersionsAsync.mock.calls[0][3];
const endTime = mockFetchObserveVersionsAsync.mock.calls[0][4];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days combined with --start', async () => {
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days combined with --end', async () => {
const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes the context project ID to fetchObserveVersionsAsync by default', async () => {
const command = createCommand([]);
await command.runAsync();
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
expect(appId).toBe(projectId);
});
it('uses --project-id flag when provided', async () => {
const command = createCommand(['--project-id', 'override-project-id']);
await command.runAsync();
const appId = mockFetchObserveVersionsAsync.mock.calls[0][1];
expect(appId).toBe('override-project-id');
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
mockFetchObserveVersionsAsync.mockResolvedValue([
{
platform: AppPlatform.Ios,
appVersions: [
{
appVersion: '1.0.0',
firstSeenAt: '2025-06-01T00:00:00.000Z',
eventCount: 100,
uniqueUserCount: 50,
buildNumbers: [],
updates: [],
metrics: [],
},
],
},
]);
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveVersionsJson).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
it('calls buildObserveVersionsTable for non-json output', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveVersionsTable).toHaveBeenCalled();
});
});
@@ -1,180 +0,0 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import {
EventsOrderPreset,
fetchObserveEventsAsync,
fetchTotalEventCountAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override hidden = true;
static override description = 'display individual app performance events ordered by metric value';
static override args = {
metric: Args.string({
description: 'Metric to query (e.g. tti, cold_launch)',
required: false,
options: Object.keys(METRIC_ALIASES),
}),
};
static override flags = {
sort: Flags.option({
description: 'Sort order for events',
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
required: false,
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
})(),
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
limit: getLimitFlagWithCustomValues({
defaultTo: DEFAULT_EVENTS_LIMIT,
limit: 100,
}),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'app-version': Flags.string({
description: 'Filter by app version',
}),
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
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,
})
);
}
}
}
@@ -1,159 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetrics extends EasCommand {
static override hidden = true;
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
metric: Flags.option({
description: 'Metric name to display (can be specified multiple times).',
multiple: true,
options: Object.keys(METRIC_ALIASES),
})(),
stat: Flags.option({
description: 'Statistic to display per metric (can be specified multiple times)',
multiple: true,
options: DEFAULT_STATS_JSON,
})(),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
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,
})
);
}
}
}
@@ -1,96 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
import { resolveTimeRange } from '../../observe/startAndEndTime';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveVersions extends EasCommand {
static override hidden = true;
static override description = 'display app versions with build and update details';
static override flags = {
platform: Flags.option({
description: 'Filter by platform',
options: Object.values(AppObservePlatform).map(s => s.toLowerCase()),
})(),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days'],
}),
days: Flags.integer({
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'project-id': Flags.string({
description: 'EAS project ID (defaults to the project ID of the current directory)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
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));
}
}
}
@@ -1,256 +0,0 @@
import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
AppPlatform,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../graphql/generated';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
};
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
([type, value]) => [value, type]
)
) as Record<string, DeviceRunSessionType>;
type ReadinessResult = { ready: true; message: string } | { ready: false };
type ReadinessChecker = (logMessages: readonly string[]) => ReadinessResult;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get the credentials to connect to it with the CLI tool of your choice';
static override flags = {
platform: Flags.option({
description: 'Device platform',
options: ['android', 'ios'] as const,
required: true,
})(),
type: Flags.option({
description: 'Type of device run session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
})(),
'package-version': Flags.string({
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStart);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStart, {
nonInteractive: flags['non-interactive'],
});
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
const createSpinner = ora('🚀 Creating device run session').start();
let deviceRunSessionId: string;
try {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
});
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
const jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
createSpinner.succeed(
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
);
} catch (err) {
createSpinner.fail('Failed to create device run session');
throw err;
}
const checkReadiness = getReadinessCheckerForType(flags.type);
const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start();
const deadline = Date.now() + POLL_TIMEOUT_MS;
let result: ReadinessResult = { ready: false };
try {
while (Date.now() < deadline) {
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
if (
session.status === DeviceRunSessionStatus.Errored ||
session.status === DeviceRunSessionStatus.Stopped
) {
throw new Error(
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready.`
);
}
const jobRunStatus = session.turtleJobRun?.status;
if (
jobRunStatus === JobRunStatus.Errored ||
jobRunStatus === JobRunStatus.Canceled ||
jobRunStatus === JobRunStatus.Finished
) {
throw new Error(
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready.`
);
}
const logMessages = await fetchLogMessagesAsync(session.turtleJobRun?.logFileUrls ?? []);
result = checkReadiness(logMessages);
if (result.ready) {
pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`);
break;
}
await sleepAsync(POLL_INTERVAL_MS);
}
} catch (err) {
pollSpinner.fail(`Failed while polling for ${flags.type} daemon logs`);
throw err;
}
if (!result.ready) {
pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`);
throw new Error(
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start.`
);
}
Log.newLine();
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
Log.newLine();
Log.log(result.message);
Log.newLine();
Log.log(
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
);
}
}
function getReadinessCheckerForType(type: string): ReadinessChecker {
switch (type) {
case DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice]:
return checkAgentDeviceReadiness;
default:
throw new Error(`Unsupported device run session type: ${type}`);
}
}
const AGENT_DEVICE_BASE_URL_ENV_VAR = 'AGENT_DEVICE_DAEMON_BASE_URL';
const AGENT_DEVICE_AUTH_TOKEN_ENV_VAR = 'AGENT_DEVICE_DAEMON_AUTH_TOKEN';
function checkAgentDeviceReadiness(logMessages: readonly string[]): ReadinessResult {
let baseUrl: string | undefined;
let authToken: string | undefined;
for (const msg of logMessages) {
baseUrl = baseUrl ?? extractExportedEnvValue(msg, AGENT_DEVICE_BASE_URL_ENV_VAR);
authToken = authToken ?? extractExportedEnvValue(msg, AGENT_DEVICE_AUTH_TOKEN_ENV_VAR);
if (baseUrl && authToken) {
break;
}
}
if (baseUrl && authToken) {
return {
ready: true,
message: [
`export ${AGENT_DEVICE_BASE_URL_ENV_VAR}='${baseUrl}'`,
`export ${AGENT_DEVICE_AUTH_TOKEN_ENV_VAR}='${authToken}'`,
].join('\n'),
};
}
return { ready: false };
}
async function fetchLogMessagesAsync(logUrls: readonly string[]): Promise<string[]> {
const messages: string[] = [];
for (const url of logUrls) {
const text = await fetchLogTextAsync(url);
if (!text) {
continue;
}
for (const line of text.split('\n')) {
if (!line.trim()) {
continue;
}
messages.push(extractLogMessage(line));
}
}
return messages;
}
async function fetchLogTextAsync(url: string): Promise<string | undefined> {
try {
const response = await fetch(url);
if (!response.ok) {
return undefined;
}
return await response.text();
} catch {
return undefined;
}
}
function extractLogMessage(line: string): string {
// Turtle job run logs are JSONL (bunyan-shaped), e.g.
// {"msg":"export FOO=\"bar\"","time":"...","logId":"..."}
// Fall back to the raw line if it's not JSON or doesn't have a string msg.
const trimmed = line.trim();
if (!trimmed.startsWith('{')) {
return line;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === 'object' && 'msg' in parsed) {
const msg = (parsed as { msg: unknown }).msg;
if (typeof msg === 'string') {
return msg;
}
}
} catch {
// not JSON, fall through
}
return line;
}
function extractExportedEnvValue(text: string, varName: string): string | undefined {
// Matches: export NAME=value | export NAME="value" | export NAME='value'
const pattern = new RegExp(`export\\s+${escapeRegExp(varName)}=(?:"([^"]*)"|'([^']*)'|(\\S+))`);
const match = pattern.exec(text);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3];
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -1,46 +0,0 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
static override flags = {
id: Flags.string({
description: 'Device run session ID',
required: true,
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStop);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive: flags['non-interactive'],
});
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
try {
const session = await DeviceRunSessionMutation.stopDeviceRunSessionAsync(
graphqlClient,
flags.id
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
throw err;
}
}
}
@@ -1,175 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateInsights from '../insights';
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeUpdateResponse(platform: 'ios' | 'android', totalInstalls = 990): any {
return {
__typename: 'Update',
id: `${platform}-update`,
platform,
insights: {
__typename: 'UpdateInsights',
id: `${platform}-i`,
totalUniqueUsers: 100,
cumulativeAverageMetrics: {
__typename: 'CumulativeAverageMetrics',
launchAssetCount: 3,
averageUpdatePayloadBytes: 1024,
},
cumulativeMetrics: {
__typename: 'CumulativeMetrics',
metricsAtLastTimestamp: {
__typename: 'CumulativeMetricsTotals',
totalInstalls,
totalFailedInstalls: 10,
},
data: {
__typename: 'UpdatesMetricsData',
labels: ['2026-04-09'],
installsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-i`,
label: 'Installs',
cumulative: [totalInstalls],
difference: [totalInstalls],
},
failedInstallsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-f`,
label: 'Failed installs',
cumulative: [10],
difference: [10],
},
},
},
},
};
}
const updatesResponse = [makeUpdateResponse('android', 500), makeUpdateResponse('ios', 990)];
describe(UpdateInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockViewUpdateGroupInsightsAsync.mockResolvedValue(updatesResponse);
});
function createCommand(argv: string[]): UpdateInsights {
const command = new UpdateInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
});
return command;
}
it('uses default 7-day window when --days/--start/--end are not provided', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
groupId: 'group-1',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
})
);
jest.useRealTimers();
});
it('uses --days when explicitly provided', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['group-1', '--days', '14']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-04-02T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
})
);
jest.useRealTimers();
});
it('uses --start and --end when provided', async () => {
const command = createCommand([
'group-1',
'--start',
'2026-01-01T00:00:00.000Z',
'--end',
'2026-02-01T00:00:00.000Z',
]);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-01-01T00:00:00.000Z',
endTime: '2026-02-01T00:00:00.000Z',
})
);
});
it('rejects --days combined with --start', async () => {
const command = createCommand([
'group-1',
'--days',
'7',
'--start',
'2026-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('filters to a single platform when --platform is passed', async () => {
const command = createCommand(['group-1', '--platform', 'ios', '--json', '--non-interactive']);
await command.runAsync();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.platforms.map((p: any) => p.platform)).toEqual(['ios']);
});
it('throws when --platform does not match any update in the group', async () => {
mockViewUpdateGroupInsightsAsync.mockResolvedValue([makeUpdateResponse('android', 500)]);
const command = createCommand(['group-1', '--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow(/no ios update/);
});
it('emits JSON when --json is passed', async () => {
const command = createCommand(['group-1', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.groupId).toBe('group-1');
expect(json.platforms.map((p: any) => p.platform)).toEqual(['android', 'ios']);
expect(json.platforms[0].totals.installs).toBe(500);
expect(json.platforms[1].totals.installs).toBe(990);
});
});
@@ -1,158 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateView from '../view';
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
UpdateQuery: { viewUpdateGroupAsync: jest.fn() },
}));
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const mockLogLog = jest.mocked(Log.log);
const updateGroup = [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-1',
branch: { __typename: 'UpdateBranch' as const, id: 'b1', name: 'main' },
actor: null,
createdAt: '2026-04-09T00:00:00.000Z',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
manifestPermalink: 'https://expo.dev/manifest/u1',
isRollBackToEmbedded: false,
rolloutPercentage: null,
codeSigningInfo: null,
gitCommitHash: null,
} as any,
];
function makeUpdateWithInsights(platform: 'ios' | 'android', totalInstalls = 990): any {
return {
__typename: 'Update',
id: `${platform}-update`,
platform,
insights: {
__typename: 'UpdateInsights',
id: `${platform}-i`,
totalUniqueUsers: 100,
cumulativeAverageMetrics: {
__typename: 'CumulativeAverageMetrics',
launchAssetCount: 3,
averageUpdatePayloadBytes: 1024,
},
cumulativeMetrics: {
__typename: 'CumulativeMetrics',
metricsAtLastTimestamp: {
__typename: 'CumulativeMetricsTotals',
totalInstalls,
totalFailedInstalls: 10,
},
data: {
__typename: 'UpdatesMetricsData',
labels: ['2026-04-09'],
installsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-i`,
label: 'Installs',
cumulative: [totalInstalls],
difference: [totalInstalls],
},
failedInstallsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-f`,
label: 'Failed installs',
cumulative: [10],
difference: [10],
},
},
},
},
};
}
const insightsResponse = [makeUpdateWithInsights('ios', 990)];
describe(UpdateView, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockViewUpdateGroupAsync.mockResolvedValue(updateGroup);
mockViewUpdateGroupInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): UpdateView {
const command = new UpdateView(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
});
return command;
}
it('does not fetch insights when --insights is not set', async () => {
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).not.toHaveBeenCalled();
});
it('rejects --days without --insights', async () => {
const command = createCommand(['group-1', '--days', '7']);
await expect(command.runAsync()).rejects.toThrow(/--insights/);
});
it('rejects --start without --insights', async () => {
const command = createCommand(['group-1', '--start', '2026-04-01']);
await expect(command.runAsync()).rejects.toThrow(/--insights/);
});
it('fetches insights when --insights is set', async () => {
const command = createCommand(['group-1', '--insights']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ groupId: 'group-1' })
);
});
it('outputs flat update array under --json without --insights', async () => {
const command = createCommand(['group-1', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0];
expect(Array.isArray(arg)).toBe(true);
});
it('renders an insights section in the table output when --insights is set without --json', async () => {
const command = createCommand(['group-1', '--insights']);
await command.runAsync();
const logged = mockLogLog.mock.calls.map(call => String(call[0] ?? '')).join('\n');
expect(logged).toMatch(/Update group insights/);
});
it('wraps output as { updates, insights } under --json --insights', async () => {
const command = createCommand(['group-1', '--json', '--insights']);
await command.runAsync();
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(arg.updates).toBeDefined();
expect(arg.insights).toBeDefined();
expect(arg.insights.platforms[0].totals.installs).toBe(990);
});
});
@@ -1,97 +0,0 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { INSIGHTS_DEFAULT_DAYS_BACK, resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import {
buildUpdateInsightsJson,
buildUpdateInsightsTable,
toUpdateInsightsSummary,
} from '../../update/insights/formatInsights';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class UpdateInsights extends EasCommand {
static override description =
'display launch, crash, unique-user, and size insights for an update group';
static override args = {
groupId: Args.string({
required: true,
description: 'The ID of an update group.',
}),
};
static override flags = {
platform: Flags.option({
description: 'Filter to a single platform.',
options: ['ios', 'android'] as const,
})(),
days: Flags.integer({
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { groupId },
flags,
} = await this.parse(UpdateInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const allUpdates = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(graphqlClient, {
groupId,
startTime,
endTime,
});
const updates = flags.platform
? allUpdates.filter(u => u.platform === flags.platform)
: allUpdates;
if (updates.length === 0) {
throw new Error(
`Update group "${groupId}" has no ${flags.platform} update (available platforms: ${allUpdates
.map(u => u.platform)
.sort()
.join(', ')}).`
);
}
const summary = toUpdateInsightsSummary(groupId, updates, { startTime, endTime, daysBack });
if (json) {
printJsonOnlyOutput(buildUpdateInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildUpdateInsightsTable(summary));
}
}
}
+3 -59
View File
@@ -1,18 +1,10 @@
import { Args, Flags } from '@oclif/core';
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import {
UpdateInsightsSummary,
buildUpdateInsightsJson,
buildUpdateInsightsTable,
toUpdateInsightsSummary,
} from '../../update/insights/formatInsights';
import {
formatUpdateGroup,
getUpdateGroupDescriptions,
@@ -31,24 +23,6 @@ export default class UpdateView extends EasCommand {
};
static override flags = {
insights: Flags.boolean({
description:
'Also show insights (launches, crash rate, unique users, payload size) for the update group.',
default: false,
}),
days: Flags.integer({
description: 'Show insights from the last N days (default 7). Only used with --insights.',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date). Only used with --insights.',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date). Only used with --insights.',
exclusive: ['days'],
}),
...EasJsonOnlyFlag,
};
@@ -59,13 +33,9 @@ export default class UpdateView extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { groupId },
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
flags: { json: jsonFlag },
} = await this.parse(UpdateView);
if (!insightsFlag && (days !== undefined || start !== undefined || end !== undefined)) {
throw new Error('--days, --start, and --end can only be used with --insights.');
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateView, { nonInteractive: true });
@@ -76,40 +46,14 @@ export default class UpdateView extends EasCommand {
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
let insightsSummary: UpdateInsightsSummary | null = null;
if (insightsFlag) {
const { daysBack, startTime, endTime } = resolveInsightsTimeRange({ days, start, end });
const updatesWithInsights = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(
graphqlClient,
{ groupId, startTime, endTime }
);
insightsSummary = toUpdateInsightsSummary(groupId, updatesWithInsights, {
startTime,
endTime,
daysBack,
});
}
if (jsonFlag) {
if (insightsSummary) {
printJsonOnlyOutput({
updates: getUpdateJsonInfosForUpdates(updatesByGroup),
insights: buildUpdateInsightsJson(insightsSummary),
});
} else {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
}
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([updatesByGroup]);
Log.log(chalk.bold('Update group:'));
Log.log(formatUpdateGroup(updateGroupDescription));
if (insightsSummary) {
Log.addNewLineIfNone();
Log.log(buildUpdateInsightsTable(insightsSummary));
}
}
}
}
@@ -12,29 +12,6 @@ const mockItems = {
},
};
const buildBundleInfo = (linked: {
appGroups: Array<{ id: string }>;
merchantIds: Array<{ id: string }>;
cloudContainers: Array<{ id: string }>;
}): any => ({
attributes: {
bundleIdCapabilities: [
{
isType: (t: string) => t === 'APPLE_PAY',
attributes: { merchantIds: linked.merchantIds },
},
{
isType: (t: string) => t === 'APP_GROUPS',
attributes: { appGroups: linked.appGroups },
},
{
isType: (t: string) => t === 'ICLOUD',
attributes: { cloudContainers: linked.cloudContainers },
},
],
},
});
function mockCapabilities(Apple: any): void {
const mockGetAsync = (existingItems: any[]): jest.Mock =>
jest.fn(() => Promise.resolve(existingItems));
@@ -58,16 +35,6 @@ function mockCapabilities(Apple: any): void {
Apple.CloudContainer.getAsync = mockGetAsync([mockItems.cloudContainer.expo]);
Apple.CloudContainer.createAsync = mockCreateAsync('XXX-icloud-2');
Apple.BundleId.infoAsync = jest.fn(() =>
Promise.resolve(
buildBundleInfo({
merchantIds: [mockItems.merchant.expo],
appGroups: [mockItems.appGroup.expo],
cloudContainers: [mockItems.cloudContainer.expo],
})
)
);
}
function mockCapabilitiesAlreadyCreated(Apple: any): void {
@@ -85,23 +52,6 @@ function mockCapabilitiesAlreadyCreated(Apple: any): void {
mockItems.cloudContainer.expo,
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
]);
Apple.BundleId.infoAsync.mockResolvedValue(
buildBundleInfo({
merchantIds: [
mockItems.merchant.expo,
{ id: 'XXX-merch-2', attributes: { identifier: 'merchant.bacon' } },
],
appGroups: [
mockItems.appGroup.expo,
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
],
cloudContainers: [
mockItems.cloudContainer.expo,
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
],
})
);
}
describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
@@ -44,20 +44,6 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
const updateRequest: UpdateCapabilityRequest = [];
// Fetch the bundle with its linked capability identifiers to check for already linked identifiers.
const bundleWithRelationships = await BundleId.infoAsync(bundleId.context, {
id: bundleId.id,
query: {
includes: [
'bundleIdCapabilities',
'bundleIdCapabilities.appGroups',
'bundleIdCapabilities.merchantIds',
'bundleIdCapabilities.cloudContainers',
],
},
});
const linkedBundleCapabilities = bundleWithRelationships.attributes.bundleIdCapabilities ?? [];
// Iterate through the supported capabilities to build the request.
for (const classifier of CapabilityIdMapping) {
const CapabilityModel = classifier.capabilityIdModel;
@@ -87,29 +73,15 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
// Get a list of all of the capability IDs that are already created on the server.
const existingIds = await CapabilityModel.getAsync(bundleId.context);
// Opaque ids of identifiers already linked to this capability.
const remoteLinkedIds = linkedBundleCapabilities.find(c => c.isType(classifier.capability))
?.attributes as Record<string, { id: string }[] | undefined> | undefined;
const alreadyLinkedOpaqueIds = new Set<string>(
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
);
// A list of server IDs for linking.
const capabilityIdOpaqueIds: string[] = [];
const capabilityIdOpaqueIds = [];
const capabilitiesWithoutRemoteModels = capabilityIds.filter(
localId => existingIds.find(model => model.attributes.identifier === localId) === undefined
);
// Iterate through all the local IDs and see if they exist on the server.
for (const localId of capabilityIds) {
let remoteIdModel = existingIds.find(model => model.attributes.identifier === localId);
// Identifier already exists.
if (remoteIdModel) {
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
// Link the existing identifier to this bundle.
linkedIds.push(remoteIdModel.attributes.identifier);
capabilityIdOpaqueIds.push(remoteIdModel.id);
}
continue;
}
for (const localId of capabilitiesWithoutRemoteModels) {
let remoteIdModel = undefined;
if (Log.isDebug) {
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
@@ -1,49 +0,0 @@
import { CombinedError } from '@urql/core';
import {
EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE,
withUpgradeRequiredErrorHandlingAsync,
} from '../client';
function makeError(message: string, extensions?: Record<string, unknown>): CombinedError {
return new CombinedError({
graphQLErrors: [{ message, extensions } as any],
});
}
describe(withUpgradeRequiredErrorHandlingAsync, () => {
it('returns data when the promise resolves successfully', async () => {
const result = await withUpgradeRequiredErrorHandlingAsync(
Promise.resolve({ data: { foo: 'bar' } } as any),
{ featureName: 'feature' }
);
expect(result).toEqual({ foo: 'bar' });
});
it('throws an upgrade-required error when the server returns the matching errorCode', async () => {
const error = makeError('Schema changed', { errorCode: EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE });
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'EAS Update insights',
})
).rejects.toThrow(/EAS Update insights is not supported by this version of eas-cli/);
});
it('throws an upgrade-required error when the server returns a "Cannot query field" validation error', async () => {
const error = makeError('Cannot query field "newField" on type "UpdateInsights".');
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'feature',
})
).rejects.toThrow(/not supported by this version of eas-cli/);
});
it('rethrows other GraphQL errors as-is', async () => {
const error = makeError('Random other error', { errorCode: 'SOMETHING_ELSE' });
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'feature',
})
).rejects.toBe(error);
});
});
-41
View File
@@ -1,10 +1,7 @@
import { CombinedError as GraphqlError, OperationResult } from '@urql/core';
import { EasCommandError } from '../commandUtils/errors';
import Log from '../log';
export const EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_CLI_UPGRADE_REQUIRED';
export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
const { data, error } = await promise;
@@ -33,42 +30,4 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
return data;
}
/**
* Wraps `withErrorHandlingAsync` for queries that hit endpoints which may evolve in
* ways that require a newer eas-cli. The server signals this by returning a GraphQL
* error with `extensions.errorCode === EAS_CLI_UPGRADE_REQUIRED`. As a fallback we
* also detect schema validation errors of the form `Cannot query field "X" on type "Y"`,
* which surface when a field has been removed without a coded error.
*
* In either case we re-throw an `EasCommandError` instructing the user to upgrade.
*/
export async function withUpgradeRequiredErrorHandlingAsync<T>(
promise: Promise<OperationResult<T>>,
{ featureName }: { featureName: string }
): Promise<T> {
try {
return await withErrorHandlingAsync(promise);
} catch (error) {
if (isUpgradeRequiredError(error)) {
throw new EasCommandError(
`${featureName} is not supported by this version of eas-cli. ` +
`Upgrade to the latest version: \`npm install -g eas-cli@latest\`.`
);
}
throw error;
}
}
function isUpgradeRequiredError(error: unknown): boolean {
if (!(error instanceof GraphqlError)) {
return false;
}
return error.graphQLErrors.some(e => {
if (e?.extensions?.errorCode === EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE) {
return true;
}
return /Cannot query field ".+" on type ".+"/.test(e?.message ?? '');
});
}
export { GraphqlError };
File diff suppressed because it is too large. Load diff
@@ -1,73 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
CreateDeviceRunSessionInput,
CreateDeviceRunSessionMutation,
CreateDeviceRunSessionMutationVariables,
StopDeviceRunSessionMutation,
StopDeviceRunSessionMutationVariables,
} from '../generated';
export const DeviceRunSessionMutation = {
async createDeviceRunSessionAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionInput: CreateDeviceRunSessionInput
): Promise<CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateDeviceRunSessionMutation, CreateDeviceRunSessionMutationVariables>(
gql`
mutation CreateDeviceRunSessionMutation($deviceRunSessionInput: CreateDeviceRunSessionInput!) {
deviceRunSession {
createDeviceRunSession(deviceRunSessionInput: $deviceRunSessionInput) {
id
status
app {
id
slug
ownerAccount {
id
name
}
}
turtleJobRun {
id
}
}
}
}
`,
{ deviceRunSessionInput },
{ noRetry: true }
)
.toPromise()
);
return data.deviceRunSession.createDeviceRunSession;
},
async stopDeviceRunSessionAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
): Promise<StopDeviceRunSessionMutation['deviceRunSession']['stopDeviceRunSession']> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<StopDeviceRunSessionMutation, StopDeviceRunSessionMutationVariables>(
gql`
mutation StopDeviceRunSessionMutation($deviceRunSessionId: ID!) {
deviceRunSession {
stopDeviceRunSession(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`,
{ deviceRunSessionId },
{ noRetry: true }
)
.toPromise()
);
return data.deviceRunSession.stopDeviceRunSession;
},
};
@@ -1,112 +0,0 @@
import gql from 'graphql-tag';
import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import {
ViewChannelRuntimeInsightsOnAppQuery,
ViewChannelRuntimeInsightsOnAppQueryVariables,
} from '../generated';
export type ChannelRuntimeInsights = NonNullable<
ViewChannelRuntimeInsightsOnAppQuery['app']['byId']['updateChannelByName']
>['runtimeInsights'];
export const ChannelInsightsQuery = {
async viewChannelRuntimeInsightsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
channelName,
runtimeVersion,
startTime,
endTime,
}: {
appId: string;
channelName: string;
runtimeVersion: string;
startTime: string;
endTime: string;
}
): Promise<ChannelRuntimeInsights> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<ViewChannelRuntimeInsightsOnAppQuery, ViewChannelRuntimeInsightsOnAppQueryVariables>(
gql`
query ViewChannelRuntimeInsightsOnApp(
$appId: String!
$channelName: String!
$runtimeVersion: String!
$timespan: InsightsTimespan!
) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
runtimeInsights {
id
embeddedUpdateTotalUniqueUsers(runtimeVersion: $runtimeVersion, timespan: $timespan)
mostPopularUpdates(runtimeVersion: $runtimeVersion, timespan: $timespan) {
id
group
message
runtimeVersion
platform
insights {
id
totalUniqueUsers(timespan: $timespan)
}
}
uniqueUsersOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
data {
labels
datasets {
id
label
data
}
}
}
cumulativeMetricsOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
data {
labels
datasets {
id
label
data
}
}
metricsAtLastTimestamp {
id
label
data
}
}
}
}
}
}
}
`,
{
appId,
channelName,
runtimeVersion,
timespan: { start: startTime, end: endTime },
},
{ additionalTypenames: ['UpdateChannel', 'Update', 'UpdateChannelRuntimeInsights'] }
)
.toPromise(),
{ featureName: 'EAS Update channel insights' }
);
const updateChannel = data.app.byId.updateChannelByName;
if (!updateChannel) {
throw new ChannelNotFoundError(`Could not find channel with the name ${channelName}`);
}
return updateChannel.runtimeInsights;
},
};
@@ -1,37 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
export const DeviceRunSessionQuery = {
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
deviceRunSessionId: string
): Promise<DeviceRunSessionByIdQuery['deviceRunSessions']['byId']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
gql`
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
turtleJobRun {
id
status
logFileUrls
}
}
}
}
`,
{ deviceRunSessionId },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
return data.deviceRunSessions.byId;
},
};
@@ -1,239 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AppObserveAppVersion,
AppObserveEvent,
AppObserveEventsFilter,
AppObserveEventsOrderBy,
AppObservePlatform,
AppObserveReleasesInput,
AppObserveTimeSeriesInput,
AppObserveTimeSeriesStatistics,
PageInfo,
} from '../generated';
import { print } from 'graphql';
import {
AppObserveAppVersionFragmentNode,
AppObserveTimeSeriesFragmentNode,
AppObserveEventFragmentNode,
} from '../types/Observe';
export type AppObserveTimeSeriesResult = {
appVersionMarkers: AppObserveAppVersion[];
eventCount: number;
statistics: AppObserveTimeSeriesStatistics;
};
type AppObserveTimeSeriesQuery = {
app: {
byId: {
id: string;
observe: {
timeSeries: AppObserveTimeSeriesResult;
};
};
};
};
type AppObserveTimeSeriesQueryVariables = {
appId: string;
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
};
type AppObserveAppVersionsQuery = {
app: {
byId: {
id: string;
observe: {
appVersions: AppObserveAppVersion[];
};
};
};
};
type AppObserveAppVersionsQueryVariables = {
appId: string;
input: AppObserveReleasesInput;
};
type AppObserveEventsQuery = {
app: {
byId: {
id: string;
observe: {
events: {
pageInfo: PageInfo;
edges: Array<{
cursor: string;
node: AppObserveEvent;
}>;
};
};
};
};
};
type AppObserveEventsQueryVariables = {
appId: string;
filter?: AppObserveEventsFilter;
first?: number;
after?: string;
orderBy?: AppObserveEventsOrderBy;
};
export const ObserveQuery = {
async timeSeriesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
metricName,
platform,
startTime,
endTime,
}: {
appId: string;
metricName: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
}
): Promise<AppObserveTimeSeriesResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
gql`
query AppObserveTimeSeries(
$appId: String!
$input: AppObserveTimeSeriesInput!
) {
app {
byId(appId: $appId) {
id
observe {
timeSeries(input: $input) {
...AppObserveTimeSeriesFragment
}
}
}
}
}
${print(AppObserveAppVersionFragmentNode)}
${print(AppObserveTimeSeriesFragmentNode)}
`,
{
appId,
input: { metricName, platform, startTime, endTime },
}
)
.toPromise()
);
return data.app.byId.observe.timeSeries;
},
async appVersionsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
platform,
startTime,
endTime,
metricNames,
}: {
appId: string;
platform: AppObservePlatform;
startTime: string;
endTime: string;
metricNames?: string[];
}
): Promise<AppObserveAppVersion[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveAppVersionsQuery, AppObserveAppVersionsQueryVariables>(
gql`
query AppObserveAppVersions(
$appId: String!
$input: AppObserveReleasesInput!
) {
app {
byId(appId: $appId) {
id
observe {
appVersions(input: $input) {
...AppObserveAppVersionFragment
}
}
}
}
}
${print(AppObserveAppVersionFragmentNode)}
`,
{
appId,
input: { platform, startTime, endTime, ...(metricNames && { metricNames }) },
}
)
.toPromise()
);
return data.app.byId.observe.appVersions;
},
async eventsAsync(
graphqlClient: ExpoGraphqlClient,
variables: AppObserveEventsQueryVariables
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
gql`
query AppObserveEvents(
$appId: String!
$filter: AppObserveEventsFilter
$first: Int
$after: String
$orderBy: AppObserveEventsOrderBy
) {
app {
byId(appId: $appId) {
id
observe {
events(
filter: $filter
first: $first
after: $after
orderBy: $orderBy
) {
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
edges {
cursor
node {
id
...AppObserveEventFragment
}
}
}
}
}
}
}
${print(AppObserveEventFragmentNode)}
`,
variables
)
.toPromise()
);
const { edges, pageInfo } = data.app.byId.observe.events;
return {
events: edges.map(edge => edge.node),
pageInfo,
};
},
};
@@ -1,67 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import { ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables } from '../generated';
export type UpdateWithInsightsObject = ViewUpdateGroupInsightsQuery['updatesByGroup'][number];
export const UpdateInsightsQuery = {
async viewUpdateGroupInsightsAsync(
graphqlClient: ExpoGraphqlClient,
{ groupId, startTime, endTime }: { groupId: string; startTime: string; endTime: string }
): Promise<UpdateWithInsightsObject[]> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables>(
gql`
query ViewUpdateGroupInsights($groupId: ID!, $timespan: InsightsTimespan!) {
updatesByGroup(group: $groupId) {
id
platform
insights {
id
totalUniqueUsers(timespan: $timespan)
cumulativeAverageMetrics {
launchAssetCount
averageUpdatePayloadBytes
}
cumulativeMetrics(timespan: $timespan) {
metricsAtLastTimestamp {
totalInstalls
totalFailedInstalls
}
data {
labels
installsDataset {
id
label
cumulative
difference
}
failedInstallsDataset {
id
label
cumulative
difference
}
}
}
}
}
}
`,
{ groupId, timespan: { start: startTime, end: endTime } },
{ additionalTypenames: ['Update', 'UpdateInsights'] }
)
.toPromise(),
{ featureName: 'EAS Update insights' }
);
if (data.updatesByGroup.length === 0) {
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
}
return data.updatesByGroup;
},
};
@@ -1,84 +0,0 @@
import gql from 'graphql-tag';
export const AppObserveTimeSeriesFragmentNode = gql`
fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries {
appVersionMarkers {
...AppObserveAppVersionFragment
}
eventCount
statistics {
min
max
median
average
p80
p90
p99
}
}
`;
export const AppObserveEventFragmentNode = gql`
fragment AppObserveEventFragment on AppObserveEvent {
id
metricName
metricValue
timestamp
appVersion
appBuildNumber
appUpdateId
deviceModel
deviceOs
deviceOsVersion
countryCode
sessionId
easClientId
customParams
}
`;
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
}
}
}
`;
@@ -1,18 +0,0 @@
import dateFormat from 'dateformat';
export interface InsightsTimespanFields {
startTime: string;
endTime: string;
daysBack?: number;
}
export function formatTimespan(timespan: InsightsTimespanFields): string {
if (timespan.daysBack) {
return `last ${timespan.daysBack} day${timespan.daysBack === 1 ? '' : 's'}`;
}
return `${toDateOnly(timespan.startTime)} to ${toDateOnly(timespan.endTime)}`;
}
export function toDateOnly(isoTimestamp: string): string {
return dateFormat(new Date(isoTimestamp), 'UTC:yyyy-mm-dd');
}
@@ -1,12 +0,0 @@
import { resolveTimeRange } from '../observe/startAndEndTime';
export const INSIGHTS_DEFAULT_DAYS_BACK = 7;
export function resolveInsightsTimeRange(flags: { days?: number; start?: string; end?: string }): {
daysBack?: number;
startTime: string;
endTime: string;
} {
const days = flags.days ?? (flags.start ? undefined : INSIGHTS_DEFAULT_DAYS_BACK);
return resolveTimeRange({ ...flags, days });
}
@@ -20,6 +20,7 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -257,4 +258,12 @@ export class AppleConfigReader {
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the privacy data usage block (Privacy Nutrition Labels), or `null` if
* not configured. When `null`, the data usage task is a no-op.
*/
public getDataUsage(): AppleDataUsage | null {
return this.schema.privacy?.dataUsage ?? null;
}
}
@@ -17,6 +17,7 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -218,6 +219,24 @@ export class AppleConfigWriter {
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
/**
* Set the privacy data usage block. Pass `null` to clear it (e.g. when the
* remote app has no data usage entries declared yet).
*/
public setDataUsage(dataUsage: AppleDataUsage | null): void {
if (!dataUsage) {
if (this.schema.privacy) {
delete this.schema.privacy.dataUsage;
if (Object.keys(this.schema.privacy).length === 0) {
delete this.schema.privacy;
}
}
return;
}
this.schema.privacy = this.schema.privacy ?? {};
this.schema.privacy.dataUsage = dataUsage;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
public setAppClipDefaultExperience(
attributes: Pick<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
+3 -1
View File
@@ -5,6 +5,7 @@ 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';
import type { DataUsageData } from './tasks/data-usage';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData;
AppClipData &
DataUsageData;
/**
* 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') as any;
getRequestClient().defaults.adapter = getAdapter('http');
@@ -0,0 +1,403 @@
import {
App,
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData } from '../../data';
import { DataUsageTask } from '../data-usage';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeRow(
id: string,
category: AppDataUsageCategoryId | null,
purpose: AppDataUsagePurposeId | null,
protection: AppDataUsageDataProtectionId | null
): AppDataUsage {
const row = new AppDataUsage(requestContext, id, {
category: category ? ({ id: category } as any) : undefined,
purpose: purpose ? ({ id: purpose } as any) : undefined,
dataProtection: protection ? ({ id: protection } as any) : undefined,
} as any);
// Stub deleteAsync so the diff path doesn't actually hit the network.
(row as any).deleteAsync = jest.fn().mockResolvedValue(undefined);
return row;
}
function makeApp(): App {
const app = new App(requestContext, 'app-id', {} as any);
(app as any).getAppDataUsagesAsync = jest.fn().mockResolvedValue([]);
(app as any).getAppDataUsagesPublishStateAsync = jest.fn().mockResolvedValue([]);
(app as any).createAppDataUsageAsync = jest.fn(async (params: any) => {
return makeRow(
`created-${params.appDataUsageCategory ?? 'none'}-${params.appDataUsagePurpose ?? 'none'}-${
params.appDataUsageProtection ?? 'none'
}`,
params.appDataUsageCategory ?? null,
params.appDataUsagePurpose ?? null,
params.appDataUsageProtection ?? null
);
});
return app;
}
function makePublishState(): AppDataUsagesPublishState {
const state = new AppDataUsagesPublishState(requestContext, 'app-id', {
published: false,
lastPublished: '',
lastPublishedBy: '',
} as any);
(state as any).updateAsync = jest.fn().mockResolvedValue(state);
return state;
}
describe(DataUsageTask, () => {
describe('prepareAsync', () => {
it('loads existing data usage rows and publish state', async () => {
const app = makeApp();
const existing = [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
(app as any).getAppDataUsagesAsync.mockResolvedValue(existing);
const publishState = makePublishState();
(app as any).getAppDataUsagesPublishStateAsync.mockResolvedValue([publishState]);
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toBe(existing);
expect(context.dataUsagesPublishState).toBe(publishState);
});
it('treats 404s as empty state', async () => {
const app = makeApp();
(app as any).getAppDataUsagesAsync.mockRejectedValue({ response: { status: 404 } });
(app as any).getAppDataUsagesPublishStateAsync.mockRejectedValue({
response: { status: 404 },
});
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toEqual([]);
expect(context.dataUsagesPublishState).toBeNull();
});
});
describe('downloadAsync', () => {
it('writes nothing when there are no rows', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: { dataUsages: [], dataUsagesPublishState: null } as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith(null);
});
it('collapses a single row into a category entry', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
});
it('groups multiple rows by category and dedupes purposes/protections', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r3',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_NOT_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
const arg = (writer.setDataUsage as jest.Mock).mock.calls[0][0];
expect(arg.categories).toHaveLength(2);
const contacts = arg.categories.find((c: any) => c.category === 'CONTACTS');
expect(contacts.purposes.sort()).toEqual(['ANALYTICS', 'APP_FUNCTIONALITY']);
expect(contacts.protections).toEqual(['DATA_LINKED_TO_YOU']);
const location = arg.categories.find((c: any) => c.category === 'PRECISE_LOCATION');
expect(location.purposes).toEqual(['APP_FUNCTIONALITY']);
expect(location.protections).toEqual(['DATA_NOT_LINKED_TO_YOU']);
});
it('emits dataNotCollected when only the sentinel row is present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [makeRow('r1', null, null, AppDataUsageDataProtectionId.DATA_NOT_COLLECTED)],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({ dataNotCollected: true });
});
});
describe('uploadAsync', () => {
it('skips when no data usage is configured', async () => {
const app = makeApp();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: null,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
});
it('creates rows for each (category × purpose × protection) tuple', async () => {
const app = makeApp();
const publishState = makePublishState();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(2);
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('deletes rows that are no longer in the config and creates new ones', async () => {
const app = makeApp();
const publishState = makePublishState();
const stale = makeRow(
'stale',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.OTHER_PURPOSES,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const kept = makeRow(
'kept',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
(app as any).getAppDataUsagesAsync.mockResolvedValue([stale, kept]);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [stale, kept],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
{
category: 'EMAIL_ADDRESS',
purposes: ['APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
// stale row deleted, EMAIL_ADDRESS row created, CONTACTS untouched.
expect((stale as any).deleteAsync).toHaveBeenCalled();
expect((kept as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(1);
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledWith({
appDataUsageCategory: 'EMAIL_ADDRESS',
appDataUsagePurpose: 'APP_FUNCTIONALITY',
appDataUsageProtection: 'DATA_LINKED_TO_YOU',
});
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('makes no API calls when the config matches existing rows exactly', async () => {
const app = makeApp();
const publishState = makePublishState();
const existing = makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [existing],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((existing as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
// Publish state is still flipped to ensure published.
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
});
describe('round-trip', () => {
it('downloads then uploads without making mutation calls', async () => {
// Use the real writer (not the auto-mock) so we can read back the
// serialized schema and feed it into the upload path.
const RealWriter = jest.requireActual('../../config/writer')
.AppleConfigWriter as typeof AppleConfigWriter;
// Pull side: collapse rows into config.
const writer = new RealWriter();
const downloadApp = makeApp();
const rows = [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
await new DataUsageTask().downloadAsync({
config: writer,
context: { app: downloadApp, dataUsages: rows, dataUsagesPublishState: null } as any,
});
// The serialized config should now contain the rows.
expect(writer.schema.privacy?.dataUsage).toEqual({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
// Push side: feed the config back through the reader and verify nothing
// is mutated since the existing rows already match.
const uploadApp = makeApp();
const publishState = makePublishState();
const reader = new AppleConfigReader(writer.schema);
await new DataUsageTask().uploadAsync({
config: reader,
context: {
app: uploadApp,
projectDir: '/p',
dataUsages: rows,
dataUsagesPublishState: publishState,
} as any,
});
expect((uploadApp as any).createAppDataUsageAsync).not.toHaveBeenCalled();
for (const row of rows) {
expect((row as any).deleteAsync).not.toHaveBeenCalled();
}
});
});
});
@@ -11,18 +11,9 @@ import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
import Log from '../../../../log';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
jest.mock('../../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
@@ -386,71 +377,6 @@ describe(PreviewsTask, () => {
);
});
it('warns and skips when preview type uses screenshot display type (APP_ prefix)', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
APP_IPHONE_67: './previews/demo.mp4',
} as any,
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('APP_IPHONE_67'));
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('IPHONE_67'));
});
it('warns and skips completely unknown preview types', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
FAKE_DEVICE: './previews/demo.mp4',
} as any,
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('FAKE_DEVICE'));
// Should not suggest stripping APP_ prefix for non-APP_ types
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Did you mean'));
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
@@ -517,54 +443,5 @@ 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();
});
});
});
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more