mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dbc19a5a | ||
|
|
84fadc4eac |
No files matched your search
+2
-46
@@ -8,61 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 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
|
||||
|
||||
@@ -70,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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.9.0",
|
||||
"version": "18.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.9.0",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.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.9.0",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.9.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.9.0"
|
||||
"@expo/steps": "18.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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',
|
||||
|
||||
+95
-221
@@ -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)
|
||||
@@ -117,9 +116,6 @@ eas --help COMMAND
|
||||
* [`eas help [COMMAND]`](#eas-help-command)
|
||||
* [`eas init`](#eas-init)
|
||||
* [`eas init:onboarding [TARGET_PROJECT_DIRECTORY]`](#eas-initonboarding-target_project_directory)
|
||||
* [`eas integrations:asc:connect`](#eas-integrationsascconnect)
|
||||
* [`eas integrations:asc:disconnect`](#eas-integrationsascdisconnect)
|
||||
* [`eas integrations:asc:status`](#eas-integrationsascstatus)
|
||||
* [`eas login`](#eas-login)
|
||||
* [`eas logout`](#eas-logout)
|
||||
* [`eas metadata:lint`](#eas-metadatalint)
|
||||
@@ -137,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)
|
||||
@@ -183,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -200,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -222,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.9.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -239,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -253,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.9.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -305,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.9.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -326,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.9.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -346,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.9.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -366,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.9.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -389,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.9.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -427,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.9.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -447,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.9.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -465,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.9.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -485,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.9.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -493,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
|
||||
@@ -501,40 +496,33 @@ 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.9.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:download [--build-id <value> | --fingerprint <value> | -p ios|android | --dev-client]
|
||||
[--all-artifacts] [--json] [--non-interactive]
|
||||
$ eas build:download --fingerprint <value> [-p ios|android] [--dev-client] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--all-artifacts Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in
|
||||
addition to the application archive. Without this flag, only the application archive is
|
||||
downloaded and the command errors if it is missing.
|
||||
--build-id=<value> ID of the build to download. Mutually exclusive with --fingerprint, --platform, and
|
||||
--dev-client; the platform is derived from the build itself.
|
||||
--[no-]dev-client Filter only dev-client builds.
|
||||
--fingerprint=<value> Fingerprint hash of the build to download
|
||||
--fingerprint=<value> (required) Fingerprint hash of the build to download
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
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.9.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -572,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.9.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -612,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.9.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -642,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.9.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -668,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.9.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -720,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.9.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -738,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.9.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -756,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.9.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -773,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.9.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -794,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.9.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -815,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.9.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -837,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.9.0/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.9.0/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`
|
||||
|
||||
@@ -881,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.9.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -903,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.9.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -925,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.9.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -960,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.9.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -983,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.9.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -1004,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.9.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1021,7 +985,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1039,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.9.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1068,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.9.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1093,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.9.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1114,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.9.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1135,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.9.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1172,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.9.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1192,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.9.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1213,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.9.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1234,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.9.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1248,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.9.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1262,7 +1226,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1294,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.9.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1320,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.9.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1342,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.9.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1370,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.9.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1398,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.9.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1421,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.9.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1443,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.9.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1477,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.9.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1521,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.9.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1552,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.9.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1611,65 +1575,6 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
## `eas integrations:asc:connect`
|
||||
|
||||
connect a project to an App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:connect [--api-key-id <value>] [--asc-app-id <value>] [--bundle-id <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--api-key-id=<value> Apple App Store Connect API Key ID
|
||||
--asc-app-id=<value> App Store Connect app identifier
|
||||
--bundle-id=<value> Filter discovered apps by bundle identifier
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
disconnect the current project from its App Store Connect app
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:disconnect [--yes] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--yes Skip confirmation prompt
|
||||
|
||||
DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
show the App Store Connect app link status for the current project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas integrations:asc:status [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
log in with your Expo account
|
||||
@@ -1720,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.9.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1738,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.9.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1756,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.9.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1808,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.9.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1822,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.9.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1845,7 +1750,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1869,7 +1774,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1887,7 +1792,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1920,7 +1825,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1963,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.9.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1984,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.9.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -2005,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.9.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -2030,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.9.0/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.9.0/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`
|
||||
|
||||
@@ -2084,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.9.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2122,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.9.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2150,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.9.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2180,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.9.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2201,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.9.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2209,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.9.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2246,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.9.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2268,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.9.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2288,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.9.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2307,7 +2181,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2330,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.9.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2347,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.9.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2468,7 +2342,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2488,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.9.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2512,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.9.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2552,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.9.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2574,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.9.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2602,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.9.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2622,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.9.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2644,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.9.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+531
-5374
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.9.0",
|
||||
"version": "18.5.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.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.9.0",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -201,15 +201,9 @@
|
||||
"env": {
|
||||
"description": "manage project and account environment variables"
|
||||
},
|
||||
"integrations": {
|
||||
"description": "manage service connections"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"observe": {
|
||||
"description": "monitor app performance metrics"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
|
||||
@@ -800,6 +800,65 @@
|
||||
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
|
||||
}
|
||||
},
|
||||
"ApplePricing": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App pricing configuration. NOTE: Apple migrated to base-territory pricing in 2023. EAS metadata currently uses the legacy `appPriceTier` API exposed by @expo/apple-utils.",
|
||||
"properties": {
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"description": "App Store price tier id (e.g. \"0\" for free, \"1\" for tier 1).",
|
||||
"defaultSnippets": [
|
||||
{ "label": "Free", "body": "0" },
|
||||
{ "label": "Tier 1", "body": "1" }
|
||||
]
|
||||
},
|
||||
"schedule": {
|
||||
"type": "array",
|
||||
"description": "Future scheduled price changes. Each entry switches the app to the given tier on `startDate`. Currently parsed but not pushed (the underlying appPriceSchedules endpoint is not yet wrapped by @expo/apple-utils).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["startDate", "tier"],
|
||||
"properties": {
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"description": "ISO-8601 date the new price tier takes effect.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"description": "App Store price tier id (e.g. \"0\" for free)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAvailability": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g. \"USA\", \"GBR\", \"JPN\") which match App Store Connect Territory ids.",
|
||||
"properties": {
|
||||
"territories": {
|
||||
"description": "Territories the app is available in. Use the literal string \"all\" to make the app available worldwide.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "ISO-3166 alpha-3 territory code (e.g. \"USA\")."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["all"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAgeRatingOverride": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
@@ -1034,6 +1093,14 @@
|
||||
"appClip": {
|
||||
"$ref": "#/definitions/apple/AppleAppClip",
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
|
||||
},
|
||||
"pricing": {
|
||||
"$ref": "#/definitions/apple/ApplePricing",
|
||||
"description": "App pricing configuration (price tier + scheduled changes)."
|
||||
},
|
||||
"availability": {
|
||||
"$ref": "#/definitions/apple/AppleAvailability",
|
||||
"description": "Territory availability configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform, BuildFragment } from '../../../graphql/generated';
|
||||
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Download from '../download';
|
||||
|
||||
jest.mock('../../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockByIdAsync = jest.mocked(BuildQuery.byIdAsync);
|
||||
const mockViewBuildsOnAppAsync = jest.mocked(BuildQuery.viewBuildsOnAppAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeBuild(overrides: Partial<BuildFragment> = {}): BuildFragment {
|
||||
return {
|
||||
id: 'build-123',
|
||||
platform: AppPlatform.Ios,
|
||||
project: { id: 'project-1' },
|
||||
artifacts: {
|
||||
applicationArchiveUrl: 'https://example.com/app.tar.gz',
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as BuildFragment;
|
||||
}
|
||||
|
||||
describe(Download, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): Download {
|
||||
const command = new Download(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
// Stub the file-system-touching helpers so we don't hit disk.
|
||||
jest
|
||||
.spyOn(command as any, 'getPathToBuildArtifactAsync')
|
||||
.mockResolvedValue('/cache/path/to/build.app');
|
||||
jest.spyOn(command as any, 'downloadExtraArtifactsAsync').mockResolvedValue({});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('errors when neither --build-id nor --fingerprint is provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Either --build-id or --fingerprint is required.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches build by ID when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
|
||||
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const command = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewBuildsOnAppAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockViewBuildsOnAppAsync.mock.calls[0][1].filter?.fingerprintHash).toBe('fp-abc');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses platform from the build itself when --build-id is provided', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild({ platform: AppPlatform.Android }));
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await command.runAsync();
|
||||
|
||||
const getPathSpy = jest.spyOn(command as any, 'getPathToBuildArtifactAsync');
|
||||
// The spy was set up after instantiation; just ensure runAsync used the build's platform
|
||||
// by checking that no platform prompt was needed.
|
||||
expect(getPathSpy).toBeDefined();
|
||||
});
|
||||
|
||||
it('errors with a helpful message when applicationArchiveUrl is missing and other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: 'https://example.com/xcode-logs.tar.gz',
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/Other artifacts are available \(buildArtifacts, xcodeBuildLogs\); re-run with --all-artifacts/
|
||||
);
|
||||
});
|
||||
|
||||
it('errors with the basic message when applicationArchiveUrl is missing and no other artifacts exist', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: null,
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Build does not have an application archive url');
|
||||
});
|
||||
|
||||
it('does not throw when applicationArchiveUrl is missing but --all-artifacts is set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeBuild({
|
||||
artifacts: {
|
||||
applicationArchiveUrl: null,
|
||||
buildArtifactsUrl: 'https://example.com/build-artifacts.tar.gz',
|
||||
xcodeBuildLogsUrl: null,
|
||||
buildUrl: null,
|
||||
} as any,
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--build-id', 'build-123', '--all-artifacts']);
|
||||
const downloadExtrasSpy = jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/build-123-artifacts/build-artifacts.tar.gz' });
|
||||
|
||||
await expect(command.runAsync()).resolves.not.toThrow();
|
||||
expect(downloadExtrasSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads extras only when --all-artifacts is set, regardless of --build-id vs --fingerprint', async () => {
|
||||
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
|
||||
|
||||
const commandWithoutFlag = createCommand(['--fingerprint', 'fp-abc', '--platform', 'ios']);
|
||||
const downloadExtrasSpyWithout = jest
|
||||
.spyOn(commandWithoutFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({});
|
||||
await commandWithoutFlag.runAsync();
|
||||
expect(downloadExtrasSpyWithout).not.toHaveBeenCalled();
|
||||
|
||||
const commandWithFlag = createCommand([
|
||||
'--fingerprint',
|
||||
'fp-abc',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--all-artifacts',
|
||||
]);
|
||||
const downloadExtrasSpyWith = jest
|
||||
.spyOn(commandWithFlag as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/path/to/extras' });
|
||||
await commandWithFlag.runAsync();
|
||||
expect(downloadExtrasSpyWith).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with path and extras when --json --all-artifacts are set', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeBuild());
|
||||
|
||||
const command = createCommand([
|
||||
'--build-id',
|
||||
'build-123',
|
||||
'--all-artifacts',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
jest
|
||||
.spyOn(command as any, 'downloadExtraArtifactsAsync')
|
||||
.mockResolvedValue({ buildArtifacts: '/cache/extras/build-artifacts.tar.gz' });
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
path: '/cache/path/to/build.app',
|
||||
buildArtifacts: '/cache/extras/build-artifacts.tar.gz',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,8 +1,7 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Flags } from '@oclif/core';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { pathExists } from 'fs-extra';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -16,43 +15,24 @@ import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getEasBuildRunCachedAppPath } from '../../run/run';
|
||||
import {
|
||||
downloadAndMaybeExtractAppAsync,
|
||||
downloadFileWithProgressTrackerAsync,
|
||||
} from '../../utils/download';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getEasBuildRunCacheDirectoryPath } from '../../utils/paths';
|
||||
|
||||
export default class Download extends EasCommand {
|
||||
static override description =
|
||||
'download a simulator/emulator build by build ID or fingerprint hash';
|
||||
|
||||
static override description = 'download simulator/emulator builds for a given fingerprint hash';
|
||||
|
||||
static override flags = {
|
||||
'build-id': Flags.string({
|
||||
description:
|
||||
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
|
||||
exclusive: ['fingerprint', 'platform', 'dev-client'],
|
||||
}),
|
||||
fingerprint: Flags.string({
|
||||
description: 'Fingerprint hash of the build to download',
|
||||
exclusive: ['build-id'],
|
||||
required: true,
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID] as const,
|
||||
exclusive: ['build-id'],
|
||||
})(),
|
||||
'dev-client': Flags.boolean({
|
||||
description: 'Filter only dev-client builds.',
|
||||
allowNo: true,
|
||||
exclusive: ['build-id'],
|
||||
}),
|
||||
'all-artifacts': Flags.boolean({
|
||||
description:
|
||||
'Download all available build artifacts (build artifacts archive, Xcode logs, etc.) in addition to the application archive. Without this flag, only the application archive is downloaded and the command errors if it is missing.',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -65,20 +45,14 @@ export default class Download extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
'build-id': buildId,
|
||||
platform,
|
||||
fingerprint,
|
||||
'dev-client': developmentClient,
|
||||
'all-artifacts': allArtifacts,
|
||||
...rawFlags
|
||||
},
|
||||
} = await this.parse(Download);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
|
||||
|
||||
if (!buildId && !fingerprint) {
|
||||
throw new Error('Either --build-id or --fingerprint is required.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
@@ -90,113 +64,24 @@ export default class Download extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let build: BuildFragment;
|
||||
let selectedPlatform: AppPlatform;
|
||||
if (buildId) {
|
||||
build = await this.getBuildByIdAsync({ graphqlClient, buildId });
|
||||
selectedPlatform = build.platform;
|
||||
Log.succeed(`🎯 Found build ${chalk.bold(buildId)} on EAS servers.`);
|
||||
} else {
|
||||
selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
build = await this.getBuildByFingerprintAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint!,
|
||||
developmentClient,
|
||||
});
|
||||
}
|
||||
|
||||
let buildArtifactPath: string | null = null;
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
} else if (!allArtifacts) {
|
||||
const availableArtifacts = listAvailableExtraArtifactNames(build);
|
||||
if (availableArtifacts.length > 0) {
|
||||
throw new Error(
|
||||
`Build does not have an application archive url. Other artifacts are available (${availableArtifacts.join(', ')}); re-run with --all-artifacts to download them.`
|
||||
);
|
||||
}
|
||||
throw new Error('Build does not have an application archive url');
|
||||
}
|
||||
|
||||
let extraArtifactPaths: Record<string, string> = {};
|
||||
if (allArtifacts) {
|
||||
extraArtifactPaths = await this.downloadExtraArtifactsAsync(build);
|
||||
}
|
||||
|
||||
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
const build = await this.getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint,
|
||||
developmentClient,
|
||||
});
|
||||
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
if (jsonFlag) {
|
||||
const jsonResults = {
|
||||
...(buildArtifactPath != null && { path: buildArtifactPath }),
|
||||
...extraArtifactPaths,
|
||||
};
|
||||
const jsonResults = { path: buildArtifactPath };
|
||||
printJsonOnlyOutput(jsonResults);
|
||||
} else {
|
||||
if (buildArtifactPath != null) {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
}
|
||||
for (const [name, filePath] of Object.entries(extraArtifactPaths)) {
|
||||
Log.log(`${name} downloaded to ${chalk.bold(filePath)}`);
|
||||
}
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadExtraArtifactsAsync(build: BuildFragment): Promise<Record<string, string>> {
|
||||
const artifacts = build.artifacts;
|
||||
if (!artifacts) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const extraArtifacts: Array<{ name: string; url: string }> = [];
|
||||
if (artifacts.buildArtifactsUrl) {
|
||||
extraArtifacts.push({ name: 'buildArtifacts', url: artifacts.buildArtifactsUrl });
|
||||
}
|
||||
if (artifacts.xcodeBuildLogsUrl) {
|
||||
extraArtifacts.push({ name: 'xcodeBuildLogs', url: artifacts.xcodeBuildLogsUrl });
|
||||
}
|
||||
if (artifacts.buildUrl && artifacts.buildUrl !== artifacts.applicationArchiveUrl) {
|
||||
extraArtifacts.push({ name: 'build', url: artifacts.buildUrl });
|
||||
}
|
||||
|
||||
if (extraArtifacts.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const outputDir = path.join(getEasBuildRunCacheDirectoryPath(), `${build.id}-artifacts`);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const downloaded: Record<string, string> = {};
|
||||
for (const { name, url } of extraArtifacts) {
|
||||
const fileName = getFileNameFromUrl(url, name);
|
||||
const outputPath = path.join(outputDir, fileName);
|
||||
await downloadFileWithProgressTrackerAsync(
|
||||
url,
|
||||
outputPath,
|
||||
(ratio, total) =>
|
||||
`Downloading ${name} (${formatBytes(total * ratio)} / ${formatBytes(total)})`,
|
||||
`Successfully downloaded ${name}`
|
||||
);
|
||||
downloaded[name] = outputPath;
|
||||
}
|
||||
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
private async getBuildByIdAsync({
|
||||
graphqlClient,
|
||||
buildId,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
buildId: string;
|
||||
}): Promise<BuildFragment> {
|
||||
try {
|
||||
return await BuildQuery.byIdAsync(graphqlClient, buildId);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Could not find build with ID ${buildId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildByFingerprintAsync({
|
||||
private async getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
@@ -223,7 +108,7 @@ export default class Download extends EasCommand {
|
||||
limit: 1,
|
||||
});
|
||||
if (builds.length === 0) {
|
||||
throw new Error(
|
||||
throw new Errors.CLIError(
|
||||
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
|
||||
);
|
||||
}
|
||||
@@ -238,7 +123,7 @@ export default class Download extends EasCommand {
|
||||
build.id,
|
||||
platform
|
||||
);
|
||||
if (await fs.pathExists(cachedBuildArtifactPath)) {
|
||||
if (await pathExists(cachedBuildArtifactPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached build...`);
|
||||
return cachedBuildArtifactPath;
|
||||
@@ -282,33 +167,3 @@ async function resolvePlatformAsync({
|
||||
});
|
||||
return selectedPlatform;
|
||||
}
|
||||
|
||||
function listAvailableExtraArtifactNames(build: BuildFragment): string[] {
|
||||
const names: string[] = [];
|
||||
if (build.artifacts?.buildArtifactsUrl) {
|
||||
names.push('buildArtifacts');
|
||||
}
|
||||
if (build.artifacts?.xcodeBuildLogsUrl) {
|
||||
names.push('xcodeBuildLogs');
|
||||
}
|
||||
if (
|
||||
build.artifacts?.buildUrl &&
|
||||
build.artifacts.buildUrl !== build.artifacts.applicationArchiveUrl
|
||||
) {
|
||||
names.push('build');
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function getFileNameFromUrl(url: string, fallbackName: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const basename = path.basename(pathname);
|
||||
if (basename) {
|
||||
return basename;
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,371 +0,0 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../../commandUtils/errors';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import IntegrationsAscConnect from '../connect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
|
||||
jest.mock('../../../../integrations/asc/ascApiKey');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
const mockRemoteApps = [
|
||||
{
|
||||
ascAppIdentifier: '9876543210',
|
||||
bundleIdentifier: 'com.test.newapp',
|
||||
name: 'New App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe(IntegrationsAscConnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const actor = { id: 'actor-id' } as any;
|
||||
const analytics = {} as any;
|
||||
const vcsClient = {} as any;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fails when already connected', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(EasCommandError);
|
||||
});
|
||||
|
||||
it('connects successfully in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'key-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Apple key identifier in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'eas-key-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires --api-key-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--api-key-id is required');
|
||||
});
|
||||
|
||||
it('requires --asc-app-id in non-interactive mode', async () => {
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required');
|
||||
});
|
||||
|
||||
it('fails when asc-app-id is not found among discovered apps', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps');
|
||||
});
|
||||
|
||||
it('fails when passing EAS key id in non-interactive mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No App Store Connect API key found with Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when multiple keys match Apple key identifier', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'eas-key-uuid-1',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
{
|
||||
id: 'eas-key-uuid-2',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Multiple App Store Connect API keys match Apple key identifier'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when no accessible apps are discovered', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No accessible apps found on App Store Connect'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when app discovery throws', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync)
|
||||
.mockRejectedValueOnce(new Error('discovery failed'));
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('discovery failed');
|
||||
});
|
||||
|
||||
it('creates or selects key when no api-key-id is provided', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected)
|
||||
.mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]);
|
||||
jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id');
|
||||
jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps);
|
||||
jest
|
||||
.mocked(AscAppLinkMutation.createAppStoreConnectAppAsync)
|
||||
.mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' });
|
||||
|
||||
const command = new IntegrationsAscConnect(['--asc-app-id', '9876543210'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled();
|
||||
expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
ascAppIdentifier: '9876543210',
|
||||
appStoreConnectApiKeyId: 'generated-key-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import { toggleConfirmAsync } from '../../../../prompts';
|
||||
import IntegrationsAscDisconnect from '../disconnect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscDisconnect, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('disconnects successfully with --yes', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataConnected)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'asc-app-link-id'
|
||||
);
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('no-op when already disconnected', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output when already disconnected in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes', '--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "disconnect"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscDisconnect(['--yes'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels disconnection when confirmation is rejected', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as never);
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false);
|
||||
|
||||
const command = new IntegrationsAscDisconnect([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit called');
|
||||
expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import IntegrationsAscStatus from '../status';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora');
|
||||
|
||||
const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
remoteAppStoreConnectApp: {
|
||||
ascAppIdentifier: '1234567890',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
name: 'Test App',
|
||||
appStoreIconUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
describe(IntegrationsAscStatus, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays connected status', async () => {
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays disconnected status', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints json output in json mode', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action": "status"')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key gracefully', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('revoked or is no longer valid')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles invalid ASC API key with json output', async () => {
|
||||
const ascError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'App Store Connect rejected this API key with status 401. Choose a valid API key and try again.'
|
||||
),
|
||||
],
|
||||
});
|
||||
jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockRejectedValueOnce(ascError);
|
||||
|
||||
const command = new IntegrationsAscStatus(['--json'], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"status": "invalid"')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fetching status fails', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockRejectedValueOnce(new Error('status failed'));
|
||||
|
||||
const command = new IntegrationsAscStatus([], mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('status failed');
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { CredentialsContext } from '../../../credentials/context';
|
||||
import { buildJsonOutput, formatAscAppLinkStatus } from '../../../integrations/asc/utils';
|
||||
import { selectOrCreateAscApiKeyIdAsync } from '../../../integrations/asc/ascApiKey';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
type DiscoveredAscApps = Awaited<ReturnType<typeof AscAppLinkQuery.discoverAccessibleAppsAsync>>;
|
||||
|
||||
export default class IntegrationsAscConnect extends EasCommand {
|
||||
static override description = 'connect a project to an App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
'api-key-id': Flags.string({
|
||||
description: 'Apple App Store Connect API Key ID',
|
||||
}),
|
||||
'asc-app-id': Flags.string({
|
||||
description: 'App Store Connect app identifier',
|
||||
}),
|
||||
'bundle-id': Flags.string({
|
||||
description: 'Filter discovered apps by bundle identifier',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscConnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
if (!flags['api-key-id']) {
|
||||
throw new EasCommandError('--api-key-id is required in non-interactive mode.');
|
||||
}
|
||||
if (!flags['asc-app-id']) {
|
||||
throw new EasCommandError('--asc-app-id is required in non-interactive mode.');
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
loggedIn: { actor, graphqlClient },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(IntegrationsAscConnect, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const statusSpinner = ora('Checking current App Store Connect app link status').start();
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
statusSpinner.succeed('Checked current status');
|
||||
|
||||
if (metadata.appStoreConnectApp) {
|
||||
throw new EasCommandError(
|
||||
`Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas integrations:asc:disconnect')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Get ASC API key
|
||||
const keysSpinner = ora('Fetching App Store Connect API keys').start();
|
||||
const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
metadata.ownerAccount.name
|
||||
);
|
||||
keysSpinner.succeed(`Found ${keys.length} API key(s)`);
|
||||
|
||||
let apiKeyId = flags['api-key-id'];
|
||||
if (!apiKeyId) {
|
||||
const credentialsContext = new CredentialsContext({
|
||||
projectInfo: null,
|
||||
nonInteractive,
|
||||
projectDir,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
apiKeyId = await selectOrCreateAscApiKeyIdAsync({
|
||||
credentialsContext,
|
||||
existingKeys: keys,
|
||||
ownerAccount: metadata.ownerAccount,
|
||||
});
|
||||
} else {
|
||||
const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId);
|
||||
if (keysByAppleId.length > 1) {
|
||||
throw new EasCommandError(
|
||||
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
} else if (keysByAppleId.length === 1) {
|
||||
apiKeyId = keysByAppleId[0].id;
|
||||
} else {
|
||||
throw new EasCommandError(
|
||||
`No App Store Connect API key found with Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!apiKeyId) {
|
||||
throw new EasCommandError('No App Store Connect API key selected.');
|
||||
}
|
||||
|
||||
// Step 3: Discover remote apps
|
||||
const discoverSpinner = ora('Discovering App Store Connect apps').start();
|
||||
let remoteApps: DiscoveredAscApps;
|
||||
try {
|
||||
remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync(
|
||||
graphqlClient,
|
||||
apiKeyId,
|
||||
flags['bundle-id']
|
||||
);
|
||||
discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`);
|
||||
} catch (err) {
|
||||
discoverSpinner.fail('Failed to discover apps');
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (remoteApps.length === 0) {
|
||||
throw new EasCommandError(
|
||||
'No accessible apps found on App Store Connect for the selected API key.' +
|
||||
(flags['bundle-id']
|
||||
? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Select remote app
|
||||
let selectedApp: DiscoveredAscApps[number];
|
||||
if (flags['asc-app-id']) {
|
||||
const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']);
|
||||
if (!match) {
|
||||
throw new EasCommandError(
|
||||
`App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas integrations:asc:connect')} interactively to discover available apps.`
|
||||
);
|
||||
}
|
||||
selectedApp = match;
|
||||
} else {
|
||||
selectedApp = await selectAsync<DiscoveredAscApps[number]>(
|
||||
'Select an App Store Connect app:',
|
||||
remoteApps.map(app => ({
|
||||
title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`,
|
||||
value: app,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Create link
|
||||
const createSpinner = ora('Connecting project to App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, {
|
||||
appId: metadata.id,
|
||||
ascAppIdentifier: selectedApp.ascAppIdentifier,
|
||||
appStoreConnectApiKeyId: apiKeyId,
|
||||
});
|
||||
createSpinner.succeed('Connected project to App Store Connect app');
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to connect project');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 6: Refetch and display
|
||||
const refetchSpinner = ora('Verifying connection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
refetchSpinner.succeed('Verified connection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscDisconnect extends EasCommand {
|
||||
static override description = 'disconnect the current project from its App Store Connect app';
|
||||
|
||||
static override flags = {
|
||||
yes: Flags.boolean({
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscDisconnect);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscDisconnect, {
|
||||
nonInteractive: nonInteractive || flags.yes,
|
||||
});
|
||||
|
||||
// Step 1: Check current status
|
||||
const metadata = await this.fetchCurrentStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('disconnect', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.\nThe connection cannot be resolved from the CLI. Please update the API key on the Expo dashboard.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.appStoreConnectApp) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Confirm
|
||||
if (!flags.yes && !nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.'
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({
|
||||
message: 'Are you sure you wish to proceed?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.error('Canceled disconnection');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete
|
||||
const deleteSpinner = ora('Disconnecting App Store Connect app').start();
|
||||
try {
|
||||
await AscAppLinkMutation.deleteAppStoreConnectAppAsync(
|
||||
graphqlClient,
|
||||
metadata.appStoreConnectApp.id
|
||||
);
|
||||
deleteSpinner.succeed('Disconnected App Store Connect app');
|
||||
} catch (err) {
|
||||
deleteSpinner.fail('Failed to disconnect App Store Connect app');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 4: Refetch and display
|
||||
const refetchSpinner = ora('Verifying disconnection').start();
|
||||
const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, {
|
||||
useCache: false,
|
||||
});
|
||||
refetchSpinner.succeed('Verified disconnection');
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(updatedMetadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCurrentStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Checking current App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Checked current status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to check current status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
buildInvalidJsonOutput,
|
||||
buildJsonOutput,
|
||||
formatAscAppLinkStatus,
|
||||
isAscAuthenticationError,
|
||||
} from '../../../integrations/asc/utils';
|
||||
import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsAscStatus extends EasCommand {
|
||||
static override description =
|
||||
'show the App Store Connect app link status for the current project';
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsAscStatus);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsAscStatus, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const metadata = await this.fetchStatusAsync(graphqlClient, projectId);
|
||||
if (!metadata) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildInvalidJsonOutput('status', projectId));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
'The App Store Connect API key linked to this project has been revoked or is no longer valid.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildJsonOutput('status', metadata));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatAscAppLinkStatus(metadata));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<Awaited<ReturnType<typeof AscAppLinkQuery.getAppMetadataAsync>> | null> {
|
||||
const spinner = ora('Fetching App Store Connect app link status').start();
|
||||
try {
|
||||
const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId);
|
||||
spinner.succeed('Fetched App Store Connect app link status');
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
if (isAscAuthenticationError(err)) {
|
||||
spinner.fail('App Store Connect connection is invalid');
|
||||
return null;
|
||||
}
|
||||
spinner.fail('Failed to fetch App Store Connect app link status');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey
|
||||
|
||||
export enum AppStoreApiKeyPurpose {
|
||||
SUBMISSION_SERVICE = 'EAS Submit',
|
||||
ASC_APP_CONNECTION = 'EAS Connect',
|
||||
}
|
||||
|
||||
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
|
||||
@@ -101,27 +99,12 @@ async function generateAscApiKeyAsync(
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<MinimalAscApiKey> {
|
||||
await ctx.appStore.ensureAuthenticatedAsync();
|
||||
const role = await selectRoleForGeneratedAscApiKeyAsync();
|
||||
const ascApiKey = await ctx.appStore.createAscApiKeyAsync(ctx.analytics, {
|
||||
nickname: getAscApiKeyName(purpose),
|
||||
roles: [role],
|
||||
});
|
||||
return await getMinimalAscApiKeyAsync(ascApiKey);
|
||||
}
|
||||
|
||||
async function selectRoleForGeneratedAscApiKeyAsync(): Promise<UserRole> {
|
||||
return await selectAsync<UserRole>('Select role for the generated API key:', [
|
||||
{
|
||||
title: 'ADMIN (default)',
|
||||
value: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
title: 'APP_MANAGER (least privilege for app management)',
|
||||
value: UserRole.APP_MANAGER,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
|
||||
const nameParts = ['[Expo]', purpose, nanoid(10)];
|
||||
return nameParts.join(' ');
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
|
||||
import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../../utils/promptForCredentials';
|
||||
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
getAscApiKeyName,
|
||||
promptForAscApiKeyPathAsync,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
} from '../AscApiKeyUtils';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj) as K[];
|
||||
@@ -26,10 +18,7 @@ function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
|
||||
afterEach(() => {
|
||||
jest.mocked(promptAsync).mockClear();
|
||||
jest.mocked(selectAsync).mockClear();
|
||||
jest.mocked(getCredentialsFromUserAsync).mockClear();
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
});
|
||||
|
||||
describe(getAscApiKeyName, () => {
|
||||
@@ -98,84 +87,3 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
|
||||
});
|
||||
});
|
||||
|
||||
describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
it('creates a key with ADMIN role by default', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.ADMIN);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.ADMIN],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows creating a key with APP_MANAGER role', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(true);
|
||||
jest.mocked(selectAsync).mockResolvedValue(UserRole.APP_MANAGER);
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
await provideOrGenerateAscApiKeyAsync(ctx, AppStoreApiKeyPurpose.ASC_APP_CONNECTION);
|
||||
|
||||
expect(createAscApiKeyAsync).toHaveBeenCalledWith(ctx.analytics, {
|
||||
nickname: expect.any(String),
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply role override when using a user-provided key', async () => {
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockResolvedValue(false);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ keyP8Path: '/asc-api-key.p8' });
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => 'test-key-p8' as any);
|
||||
jest
|
||||
.mocked(getCredentialsFromUserAsync)
|
||||
.mockResolvedValueOnce({ keyId: 'test-key-id' })
|
||||
.mockResolvedValueOnce({ issuerId: 'test-issuer-id' });
|
||||
|
||||
const createAscApiKeyAsync = jest.fn(async () => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
createAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provideOrGenerateAscApiKeyAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
keyP8: expect.any(String),
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ProfileType } from '@expo/app-store';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
AscApiKey,
|
||||
@@ -196,10 +195,10 @@ export default class AppStoreApi {
|
||||
|
||||
public async createAscApiKeyAsync(
|
||||
analytics: Analytics,
|
||||
{ nickname, roles }: { nickname: string; roles?: UserRole[] }
|
||||
{ nickname }: { nickname: string }
|
||||
): Promise<AscApiKey> {
|
||||
const userCtx = await this.ensureUserAuthenticatedAsync();
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname, roles });
|
||||
return await createAscApiKeyAsync(analytics, userCtx, { nickname });
|
||||
}
|
||||
|
||||
public async revokeAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
|
||||
|
||||
@@ -90,23 +90,6 @@ test(`createAscApiKeyAsync`, async () => {
|
||||
expect(result).toEqual({ ...mockAscApiKeyInfo, keyP8: 'super secret' });
|
||||
});
|
||||
|
||||
test(`createAscApiKeyAsync forwards explicit roles`, async () => {
|
||||
const analytics = instance(mock<Analytics>());
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'createAsync').mockImplementation(async () => mockApiKey);
|
||||
jest.mocked(getRequestContext).mockImplementation(() => mockRequestContext);
|
||||
await createAscApiKeyAsync(analytics, mockAuthCtx, {
|
||||
nickname: 'test-name',
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
});
|
||||
expect(ApiKey.createAsync).toHaveBeenLastCalledWith(mockRequestContext, {
|
||||
nickname: 'test-name',
|
||||
allAppsVisible: true,
|
||||
roles: [UserRole.APP_MANAGER],
|
||||
keyType: ApiKeyType.PUBLIC_API,
|
||||
});
|
||||
});
|
||||
|
||||
test(`revokeAscApiKeyAsync`, async () => {
|
||||
const mockRequestContext = {};
|
||||
jest.spyOn(ApiKey, 'infoAsync').mockImplementation(async () => mockApiKey);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
+68
-783
File diff suppressed because it is too large.
Load diff
@@ -1,70 +0,0 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export const AscAppLinkMutation = {
|
||||
async createAppStoreConnectAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appStoreConnectAppInput: {
|
||||
appId: string;
|
||||
ascAppIdentifier: string;
|
||||
appStoreConnectApiKeyId: string;
|
||||
}
|
||||
): Promise<{ id: string; ascAppIdentifier: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<{
|
||||
appStoreConnectApp: {
|
||||
createAppStoreConnectApp: {
|
||||
id: string;
|
||||
ascAppIdentifier: string;
|
||||
};
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
mutation CreateAppStoreConnectApp($appStoreConnectAppInput: AppStoreConnectAppInput!) {
|
||||
appStoreConnectApp {
|
||||
createAppStoreConnectApp(appStoreConnectAppInput: $appStoreConnectAppInput) {
|
||||
id
|
||||
ascAppIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appStoreConnectAppInput }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.appStoreConnectApp.createAppStoreConnectApp;
|
||||
},
|
||||
|
||||
async deleteAppStoreConnectAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appStoreConnectAppId: string
|
||||
): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<{
|
||||
appStoreConnectApp: {
|
||||
deleteAppStoreConnectApp: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
}>(
|
||||
gql`
|
||||
mutation DeleteAppStoreConnectApp($appStoreConnectAppId: ID!) {
|
||||
appStoreConnectApp {
|
||||
deleteAppStoreConnectApp(appStoreConnectAppId: $appStoreConnectAppId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appStoreConnectAppId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user