Compare commits

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

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

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

No files matched your search

+2 -83
View File
@@ -8,98 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas update:upload-embedded` command to upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it. ([#3699](https://github.com/expo/eas-cli/pull/3699) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
### 🎉 New features
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 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))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 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))
## [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
@@ -107,7 +27,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.11.0",
"version": "18.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.11.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",
@@ -1,7 +1,10 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, rm: fsRm };
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
+2 -16
View File
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -52,7 +51,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -148,10 +146,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
return await runFastlaneGym(ctx, {
await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -173,18 +171,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -1,72 +0,0 @@
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,11 @@
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import {
BuildJob,
EasCliNpmTags,
Env,
Metadata,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -7,7 +14,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
import { isAtLeastNpm7Async } from '../utils/packageManager';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -114,6 +121,33 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
+5 -2
View File
@@ -26,7 +26,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -82,7 +82,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
+1 -3
View File
@@ -173,9 +173,7 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
@@ -22,10 +22,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -49,10 +45,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -78,10 +70,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -109,10 +97,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -133,9 +117,6 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,9 +134,6 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -175,8 +153,5 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -1,128 +0,0 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,8 +58,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -96,8 +94,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -135,8 +131,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -198,8 +192,6 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -218,7 +210,6 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -235,7 +226,6 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -253,7 +243,6 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
@@ -1,151 +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 { installPods } from '../pod';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
usePrecompiledModules = true,
}: {
expoVersion?: string;
usePrecompiledModules?: boolean;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
});
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
}),
})
);
expect(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
)
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
});
+2 -16
View File
@@ -3,7 +3,6 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
@@ -29,13 +28,7 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
): Promise<void> {
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -50,15 +43,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -131,8 +122,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -142,7 +131,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -154,8 +142,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
-9
View File
@@ -11,8 +11,6 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -23,7 +21,6 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -37,8 +34,6 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -64,8 +59,6 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -81,7 +74,6 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -94,7 +86,6 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
+1 -82
View File
@@ -1,14 +1,9 @@
import { Env, Ios } from '@expo/eas-build-job';
import { Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -17,7 +12,6 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -26,7 +20,6 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -42,77 +35,3 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
// return PRECOMPILED_MODULES_BASE_URL;
// }
//
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
// return PRECOMPILED_MODULES_BASE_URL.replace(
// `${parsedUrl.protocol}//${parsedUrl.host}`,
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
// );
// }
@@ -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,14 +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 { createMaestroTestsBuildFunction } from './functions/maestroTests';
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';
@@ -54,14 +47,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
createReadPackageJsonBuildFunction(),
createReadAppConfigBuildFunction(),
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -78,8 +67,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -94,7 +81,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
];
if (ctx.hasBuildJob()) {
@@ -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,302 +0,0 @@
import { vol } from 'memfs';
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
import { createMockLogger } from '../../../__tests__/utils/logger';
describe(readFlowName, () => {
beforeEach(() => vol.reset());
it('returns top-level `name` when present', async () => {
vol.fromJSON({
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
});
it('falls back to basename without extension when name absent', async () => {
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is empty string', async () => {
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is non-string', async () => {
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when YAML is malformed', async () => {
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file is empty', async () => {
vol.fromJSON({ '/p/login.yml': '' });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file does not exist', async () => {
expect(await readFlowName('/p/missing.yml')).toBe('missing');
});
it('only parses the first YAML document (commands document errors are ignored)', async () => {
vol.fromJSON({
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
it('handles .yaml extension', async () => {
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
});
it('handles a leading `---` document marker', async () => {
vol.fromJSON({
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
});
describe(walkDir, () => {
beforeEach(() => vol.reset());
it('collects all .yml/.yaml files recursively, sorted', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/a.yml': '',
'/p/flows/b.yaml': '',
'/p/flows/sub/c.yml': '',
'/p/flows/sub/d.YAML': '',
'/p/flows/readme.md': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual([
'/p/flows/a.yml',
'/p/flows/b.yaml',
'/p/flows/sub/c.yml',
'/p/flows/sub/d.YAML',
]);
});
it('skips files named config.yaml (case-insensitive)', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/config.yaml': '',
'/p/flows/CONFIG.yaml': '',
'/p/flows/login.yml': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual(['/p/flows/login.yml']);
});
it('returns silently when realpath fails (e.g. missing dir)', async () => {
const logger = createMockLogger();
const out: string[] = [];
await walkDir('/p/missing', new Set(), out, logger);
expect(out).toEqual([]);
});
});
describe(buildFlowNameToPathMap, () => {
beforeEach(() => vol.reset());
it('builds a map from a single file input (basename name)', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
});
it('uses top-level name when present', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
});
it('walks a directory input and adds each flow', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/login.yml': 'name: Login\n',
'/proj/.maestro/checkout.yml': '',
'/proj/.maestro/sub/profile.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['Login', '.maestro/login.yml'],
['checkout', '.maestro/checkout.yml'],
['profile', '.maestro/sub/profile.yml'],
])
);
});
it('merges file + directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/a.yml': '',
'/proj/extra/b.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', 'extra/b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['a', '.maestro/a.yml'],
['b', 'extra/b.yml'],
])
);
});
it('skips non-existent inputs with warn', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
});
it('skips workspace config.yaml inside directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
'/proj/.maestro/login.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
});
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro/config.yaml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
});
it('returns null when two files share top-level name', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/a.yml': 'name: Login\n',
'/proj/b.yml': 'name: Login\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['a.yml', 'b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
});
it('returns null when two files share basename across directories', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login/index.yml': '',
'/proj/checkout/index.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login', 'checkout'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('returns null when name collides with another file basename', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login.yml': 'name: Other\n',
'/proj/Other.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login.yml', 'Other.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('dedupes the same physical file passed via overlapping inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
expect(logger.warn).not.toHaveBeenCalled();
});
it('uses absolute fallback when input file lives outside projectRoot', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/elsewhere/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/elsewhere/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
});
it('absolute directory input under projectRoot yields project-relative children', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/proj/.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
});
it('returns null and warns on unexpected helper failure', async () => {
const logger = createMockLogger();
const map = await buildFlowNameToPathMap({
inputFlowPaths: [Symbol('bad') as unknown as string],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
);
});
});
@@ -1,18 +1,63 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with name→path map', async () => {
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -24,15 +69,18 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/junit',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
@@ -60,7 +108,7 @@ describe(parseMaestroResults, () => {
);
});
it('uses flow name as fallback path when nameToPath is null', async () => {
it('calculates retryCount from timestamp directory occurrences', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -70,33 +118,49 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', null);
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('uses flow name as fallback path when name not in map', async () => {
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', new Map());
expect(results[0].path).toBe('home');
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
@@ -115,13 +179,44 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Attempt 0: login FAILED
@@ -144,11 +239,20 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Should return 2 results — one per attempt; retryCount from filename
// Should return 2 results — one per attempt
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
@@ -194,15 +298,25 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
@@ -216,6 +330,7 @@ describe(parseMaestroResults, () => {
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -229,6 +344,7 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: all passed across shards
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -240,15 +356,25 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
expect(results).toHaveLength(4);
expect(results).toEqual([
@@ -259,8 +385,52 @@ describe(parseMaestroResults, () => {
]);
});
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
vol.fromJSON({
// Single overwritten JUnit (only has final attempt's results)
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// 2 timestamp dirs — both flows appear in both (entire suite retried)
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
// Both flows have 2 occurrences → retryCount = 1 for both
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
])
);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -279,14 +449,21 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
'/tests',
'/root/project'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
@@ -296,6 +473,51 @@ describe(parseMaestroResults, () => {
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
@@ -581,310 +803,3 @@ describe(parseJUnitTestCases, () => {
expect(results[0].properties).toEqual({});
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites>
<testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Search" status="SUCCESS" time="1.0" />
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
</testsuite>
</testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([
['Login', 'flows/login.yaml'],
['Search', 'flows/search.yaml'],
['Checkout', 'flows/checkout.yaml'],
]),
});
expect(result).toEqual(['flows/checkout.yaml']);
});
it('returns null when a failing testcase has no entry in nameToPath', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['Login', 'flows/login.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when two failing testcases share the same name (collision)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Shared" status="SUCCESS" time="1.0" />
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file does not exist', async () => {
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/missing.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file is malformed', async () => {
vol.fromJSON({
'/tmp/junit-reports/bad.xml': 'this is not xml',
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/bad.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
// fast-xml-parser can produce a partial parse from truncated XML — without
// strict validation, smart retry would only retry the visible failures and
// silently skip flows that were cut off.
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
<testcase name="B"`, // intentionally truncated mid-tag
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([
['A', 'flows/a.yaml'],
['B', 'flows/b.yaml'],
]),
});
expect(result).toBeNull();
});
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
</testsuite>`, // missing </testsuites>
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
});
describe('mergeJUnitReports', () => {
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
// Single-attempt runs should land in final_report_path with the same
// suite-level attributes (tests/failures/time) and non-testcase children
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
// rebuild path used for multi-attempt merges drops these, so the single
// case must short-circuit to a byte-equivalent copy.
const input = `<?xml version="1.0"?>
<testsuites>
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" status="SUCCESS" time="2.0" />
<system-out>boot complete</system-out>
</testsuite>
</testsuites>`;
vol.fromJSON({
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
});
await mergeJUnitReports({
sourceDir: '/tmp/junit-reports',
outputPath: '/tmp/final.xml',
});
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
expect(out).toBe(input);
});
it('keeps the latest attempt per flow name across multiple files', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0" />
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="B" status="SUCCESS" time="3.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const a = testcases.find((t: any) => t['@_name'] === 'A');
const b = testcases.find((t: any) => t['@_name'] === 'B');
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
expect(b.failure).toBeUndefined();
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
// — without a throw here, mergeJUnitReports would silently write an empty
// <testsuite> and the caller would upload a misleading empty report.
vol.fromJSON({
'/tmp/r/.gitkeep': '',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no \*\.xml files/);
});
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
// Without a throw, mergeJUnitReports would silently emit an empty merged
// document and the copy-latest fallback (which only triggers on throw)
// would never run.
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/invalid XML/);
});
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite></testsuite></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow(/no parseable testcases/);
});
it('throws when any input XML is malformed (even if others parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': 'garbage not xml',
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
// fast-xml-parser is lenient and can return partial results from truncated
// XML. Without strict validation, mergeJUnitReports would emit a merged
// report missing the cut-off flows and Phase 3's copy-latest fallback
// would never run. Validation must reject the file.
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
<testcase name="B"`, // truncated mid-tag
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" status="SUCCESS" time="1.0"/>
</testsuite>`, // missing </testsuites>
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
});
await expect(
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
).rejects.toThrow();
});
it('preserves duplicate names within the same attempt', async () => {
vol.fromJSON({
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Dup" status="SUCCESS" time="1.0" />
<testcase name="Dup" status="SUCCESS" time="2.0" />
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
out
);
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
});
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
});
it('throws when source has no xml files', async () => {
vol.fromJSON({ '/tmp/r/.keep': '' });
await expect(
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
).rejects.toThrow();
});
});
@@ -1,539 +0,0 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockedSpawn = jest.mocked(spawn);
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub the merge helper so tests that don't care about it don't
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries only the failed flows on the next attempt (junit mode)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Attempt 0 saw all 3
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
// Attempt 1 saw only the failed one
const a1Args = mockedSpawn.mock.calls[1][1]!;
expect(a1Args).toContain('flows/b.yaml');
expect(a1Args).not.toContain('flows/a.yaml');
expect(a1Args).not.toContain('flows/c.yaml');
});
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Both attempts saw all 3 flows
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('always retries all flows when output_format is not junit', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
expect(parseSpy).not.toHaveBeenCalled();
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
);
});
it('writes merged junit on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalled();
});
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
// Phase 1: final_report_path populated
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
// Retry loop: --output written into junit-reports/ (not legacy path)
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
// Phase 3: merge invoked
expect(mergeSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
});
@@ -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();
});
});
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/root/project/.maestro/home.yml': '',
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
@@ -83,11 +83,7 @@ describe(createReportMaestroTestResultsFunction, () => {
},
});
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
@@ -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,190 +0,0 @@
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import path from 'path';
import yaml from 'yaml';
const YAML_EXT = /\.ya?ml$/i;
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
export interface BuildFlowNameToPathMapArgs {
inputFlowPaths: string[];
projectRoot: string;
logger: bunyan;
}
interface FlowEntry {
name: string;
path: string;
realpath: string;
}
export async function buildFlowNameToPathMap(
args: BuildFlowNameToPathMapArgs
): Promise<Map<string, string> | null> {
try {
const flows = await discoverFlows(args);
return dedupAndDetectDuplicates(flows, args.logger);
} catch (err: any) {
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
return null;
}
}
async function discoverFlows({
inputFlowPaths,
projectRoot,
logger,
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
const realRootResult = await asyncResult(fs.realpath(projectRoot));
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
const fileLists = await Promise.all(
inputFlowPaths.map(async input => {
const abs = path.resolve(projectRoot, input);
const statResult = await asyncResult(fs.stat(abs));
if (!statResult.ok) {
logger.warn(`flow_path entry "${input}" not found, skipping`);
return [];
}
const stat = statResult.value;
if (stat.isFile() && YAML_EXT.test(abs)) {
return [abs];
}
if (stat.isDirectory()) {
const out: string[] = [];
await walkDir(abs, new Set(), out, logger);
return out;
}
return [];
})
);
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
}
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
const [name, realFileResult] = await Promise.all([
readFlowName(absFile),
asyncResult(fs.realpath(absFile)),
]);
const realFile = realFileResult.ok ? realFileResult.value : absFile;
const rel = path.relative(realRoot, realFile);
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
return { name, path: value, realpath: realFile };
}
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
const byKey = new Map<string, FlowEntry>();
for (const f of flows) {
if (!byKey.has(f.realpath)) {
byKey.set(f.realpath, f);
}
}
const unique = [...byKey.values()];
const nameToPath = new Map<string, string>();
const duplicates = new Map<string, string[]>();
for (const f of unique) {
if (nameToPath.has(f.name)) {
const existing = nameToPath.get(f.name)!;
const list = duplicates.get(f.name) ?? [existing];
list.push(f.path);
duplicates.set(f.name, list);
}
nameToPath.set(f.name, f.path);
}
if (duplicates.size > 0) {
for (const [name, paths] of duplicates) {
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
}
logger.warn(
'Smart retry disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable smart retry.'
);
return null;
}
return nameToPath;
}
export async function walkDir(
dir: string,
visited: Set<string>,
out: string[],
logger: bunyan
): Promise<void> {
// Cycle protection: dedup on realpath (fall back to the original path when
// realpath fails so we still make progress on missing or restricted dirs).
const realResult = await asyncResult(fs.realpath(dir));
const real = realResult.ok ? realResult.value : dir;
if (visited.has(real)) {
return;
}
visited.add(real);
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: any) {
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
const tgtResult = await asyncResult(fs.stat(full));
if (!tgtResult.ok) {
continue;
}
const tgt = tgtResult.value;
if (tgt.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
} else if (entry.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (entry.isFile()) {
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
}
}
}
export async function readFlowName(absFile: string): Promise<string> {
const ext = path.extname(absFile);
const fallback = path.basename(absFile, ext);
let content: string;
try {
content = await fs.readFile(absFile, 'utf-8');
} catch {
return fallback;
}
let firstDoc;
try {
const docs = yaml.parseAllDocuments(content);
firstDoc = docs[0];
} catch {
return fallback;
}
if (!firstDoc || firstDoc.errors.length > 0) {
return fallback;
}
const parsed = firstDoc.toJS();
const name = parsed?.name;
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallback;
}
function isYamlFile(name: string): boolean {
return YAML_EXT.test(name);
}
function isWorkspaceConfig(name: string): boolean {
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
}
@@ -1,6 +1,7 @@
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
@@ -13,8 +14,13 @@ export interface MaestroFlowResult {
properties: Record<string, string>;
}
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
@@ -32,9 +38,11 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
// Internal helper — not exported. Parses a single JUnit XML file.
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
const results: JUnitTestCaseResult[] = [];
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
@@ -98,20 +106,11 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
}
}
} catch {
// Malformed XML — return whatever we collected before the parser bailed.
// Skip malformed XML files
}
return results;
}
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return parseJUnitContent(content);
} catch {
return [];
}
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -125,16 +124,49 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return [];
}
const perFile = await Promise.all(
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
);
return perFile.flat();
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
@@ -163,8 +195,54 @@ export async function parseMaestroResults(
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
@@ -173,259 +251,74 @@ export async function parseMaestroResults(
}
for (const [flowName, flowEntries] of resultsByName) {
const flowPath = nameToPath?.get(flowName) ?? flowName;
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
const flowFilePath = flowPathMap.get(flowName);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: flowName;
if (flowEntries.length === 1) {
// Single result for this flow — use ai-*.json occurrence count for retryCount
// (backward compat with old-style single JUnit file that gets overwritten)
const { result } = flowEntries[0];
const occurrences = flowOccurrences.get(flowName) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: flowPath,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
retryCount,
tags: result.tags,
properties: result.properties,
});
} else {
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
}
return results;
}
/**
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
* then falls back to dumb retry — re-run everything).
*
* Caller passes a precomputed `nameToPath` map built from the user's
* `inputFlowPaths`. We translate failed testcase names to flow paths via
* that map; unknown names → null.
*/
export async function parseFailedFlowsFromJUnit(args: {
junitFile: string;
nameToPath: Map<string, string>;
}): Promise<string[] | null> {
// fast-xml-parser is lenient — truncated XML can produce a partial parse
// with some testcases dropped. Trusting that subset for smart retry would
// skip retries for cut-off flows; reject any malformed XML and fall back
// to dumb retry.
let content: string;
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const testcases = parseJUnitContent(content);
if (testcases.length === 0) {
return null;
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Two testcases with the same name (pass+fail or fail+fail) make it
// impossible to map back to a single input flow_path. Signal "unknown".
// Complementary to the map-build duplicate check (which catches the case
// where tag filter hides duplication from JUnit).
const allNameCounts = new Map<string, number>();
for (const tc of testcases) {
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
}
for (const count of allNameCounts.values()) {
if (count > 1) {
return null;
}
}
const matched: string[] = [];
for (const tc of failing) {
const p = args.nameToPath.get(tc.name);
if (p === undefined) {
return null;
}
matched.push(p);
}
return matched;
}
/**
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
* attempt's <testcase> per unique flow name (latest determined from the
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
* and writes a merged document to outputPath.
*
* Throws on empty/malformed/no-testcase input so the caller can fall back to
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
* failure rows around and produce a misleading merged report.
*/
export async function mergeJUnitReports(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
}
interface FileGroup {
attemptIndex: number;
filename: string;
content: string;
testcasesByName: Map<string, unknown[]>;
}
const contents = await Promise.all(
xmlFiles.map(async f => ({
filename: f,
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
}))
);
const fileGroups: FileGroup[] = [];
for (const { filename, content } of contents) {
if (XMLValidator.validate(content) !== true) {
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
}
let parsed: any;
try {
parsed = xmlParser.parse(content);
} catch (err) {
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
}
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
if (!Array.isArray(cases)) {
continue;
}
for (const tc of cases) {
const name = tc?.['@_name'];
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
group.push(tc);
testcasesByName.set(name, group);
}
}
if (testcasesByName.size === 0) {
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
}
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
}
// Single attempt: copy the original XML so suite-level metadata (testsuite
// attributes, <system-out>, etc.) survives. The rebuild path below would
// collapse those to a single attribute-less <testsuite>.
if (fileGroups.length === 1) {
await fs.writeFile(args.outputPath, fileGroups[0].content);
return;
}
// For each unique name, pick the file with the highest attempt index that
// contains it (ties broken by sorted filename — later wins). Preserve every
// <testcase> element from the winning file for that name, so same-attempt
// duplicates survive.
const nameToWinningFile = new Map<string, FileGroup>();
for (const group of fileGroups) {
for (const name of group.testcasesByName.keys()) {
const current = nameToWinningFile.get(name);
if (!current || group.attemptIndex >= current.attemptIndex) {
nameToWinningFile.set(name, group);
}
}
}
// Emit in first-seen order (iteration over `fileGroups` yields stable order
// matching the sorted filename list).
const testcases: unknown[] = [];
const emitted = new Set<string>();
for (const group of fileGroups) {
for (const [name, cases] of group.testcasesByName) {
if (emitted.has(name)) {
continue;
}
const winner = nameToWinningFile.get(name);
if (winner === group) {
for (const tc of cases) {
testcases.push(tc);
}
emitted.add(name);
}
}
}
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
suppressEmptyNode: true,
});
const xml = builder.build({
testsuites: {
testsuite: {
testcase: testcases,
},
},
});
await fs.writeFile(args.outputPath, xml);
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* Used as a fallback when mergeJUnitReports fails due to data issues but the
* step still needs to produce final_report_path.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
export async function copyLatestAttemptXml(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`No *.xml files found in ${args.sourceDir}`);
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins
// (same rule as mergeJUnitReports).
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
return m ? parseInt(m[1], 10) : 0;
})();
for (let i = 1; i < xmlFiles.length; i++) {
const candidate = xmlFiles[i];
const match = candidate.match(ATTEMPT_PATTERN);
const attempt = match ? parseInt(match[1], 10) : 0;
if (attempt >= winnerAttempt) {
winner = candidate;
winnerAttempt = attempt;
}
}
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
return relative;
}
@@ -1,323 +0,0 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
function parseInput<S extends z.ZodTypeAny>(
schema: S,
value: unknown,
message: string
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
}
return result.data;
}
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
// fault, so the post-loop merge should fall through to copy-latest instead
// of throwing.
function isFilesystemError(err: any): boolean {
if (!err || typeof err !== 'object') {
return false;
}
const code = err.code;
return (
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
);
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
shards: number | undefined;
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'retries',
required: false,
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'include_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'exclude_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_format',
required: false,
defaultValue: 'junit',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'platform',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger, global } = stepCtx;
const platformInput = inputs.platform.value as string | undefined;
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
const includeTags = inputs.include_tags.value as string | undefined;
const excludeTags = inputs.exclude_tags.value as string | undefined;
const platform: 'ios' | 'android' =
platformInput === 'ios' || platformInput === 'android'
? platformInput
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
? 'ios'
: 'android';
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
// this env and writes debug output under $HOME/.maestro/tests; the step
// must read from the same place or stale files leak across runs.
const home = env.HOME;
if (!home) {
throw new SystemError('HOME env var is not set');
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
'flow_path must be a non-empty array of non-empty strings.'
);
const retries = parseInput(
RetriesSchema,
inputs.retries.value,
'retries must be a non-negative integer.'
);
const shards = parseInput(
ShardsSchema,
inputs.shards.value,
'shards must be a positive integer.'
);
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
} catch (err) {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; smart retry disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Smart retry (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
});
logger.info(
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
try {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
signal,
});
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
}
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
);
} else {
flowsToRun = flowPaths;
logger.info('Test failed; could not determine failed subset, retrying all flows');
}
} else {
flowsToRun = flowPaths;
logger.info('Test failed, retrying all flows');
}
await sleepAsync(2000);
}
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Filesystem errors short-circuit straight to SystemError.
if (finalReportPath !== undefined) {
try {
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
}
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
);
}
},
});
}
@@ -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 });
}
},
});
}
@@ -1,13 +1,9 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
@@ -42,11 +38,6 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'flow_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
@@ -60,26 +51,14 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
@@ -20,7 +20,7 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
import { TurtleFetchError } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -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;
}
@@ -277,27 +277,9 @@ export async function restoreGradleCacheAsync({
logger,
});
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
logger.info(
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EAS_GRADLE_CACHE !== '1') {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -1,267 +0,0 @@
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { sleepAsync } from '../../utils/retry';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger }, { inputs, env }) => {
const packageVersion = inputs.package_version.value as string | undefined;
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureCloudflaredInstalledAsync({ env, logger });
logger.info('Ensuring bun is installed.');
await ensureBunInstalledAsync({ env, logger });
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function ensureCloudflaredInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
}
async function ensureBunInstalledAsync({
env,
logger,
}: {
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}
@@ -1,11 +1,5 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import {
@@ -50,14 +44,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
@@ -267,32 +253,3 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
},
});
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<void> {
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
if (!isAndroidEmulatorHostSupported) {
throw new UserError(
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
'Could not start the Android emulator on this runner.\n\n' +
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
' runs_on: linux-medium-nested-virtualization\n' +
' runs_on: linux-large-nested-virtualization\n\n' +
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
);
}
}
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
try {
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
return true;
} catch {
return false;
}
}
@@ -1,504 +0,0 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
jest.mock('@expo/downloader');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
schema: { name: 'TestProject' },
subSteps: [
{
title: 'Build target ModuleA',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 103.0,
signature: 'CompileC foo.c',
subSteps: [],
},
{
detailStepType: 'other',
signature: 'SwiftCompile normal bar.swift',
duration: 3.0,
startTimestamp: 101.0,
endTimestamp: 104.0,
subSteps: [],
},
],
},
{
title: 'Build target ModuleB',
subSteps: [
{
detailStepType: 'compileAssetsCatalog',
duration: 2.0,
startTimestamp: 200.0,
endTimestamp: 202.0,
signature: 'CompileAssetCatalog',
subSteps: [],
},
],
},
{
title: 'Build target TinyModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 0.1,
startTimestamp: 300.0,
endTimestamp: 300.1,
signature: 'CompileC tiny.c',
subSteps: [],
},
],
},
],
};
// Fixture: empty data
const FIXTURE_EMPTY = {
schema: { name: 'EmptyProject' },
subSteps: [],
};
// Fixture: no compile steps (only non-compile sub-steps)
const FIXTURE_NO_COMPILE = {
schema: { name: 'NoCompileProject' },
subSteps: [
{
title: 'Build target SomeTarget',
subSteps: [
{
detailStepType: 'linkCommand',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 105.0,
signature: 'Ld something',
subSteps: [],
},
],
},
],
};
// Fixture: missing timestamps
const FIXTURE_MISSING_TIMESTAMPS = {
schema: { name: 'MissingTimestamps' },
subSteps: [
{
title: 'Build target PartialModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 3.0,
signature: 'CompileC partial.c',
subSteps: [],
// no startTimestamp/endTimestamp
},
],
},
],
};
// Fixture: nested compile steps (compile step inside non-compile step)
const FIXTURE_NESTED = {
schema: { name: 'NestedProject' },
subSteps: [
{
title: 'Build target NestedModule',
subSteps: [
{
detailStepType: 'scriptExecution',
duration: 10.0,
startTimestamp: 100.0,
endTimestamp: 110.0,
signature: 'PhaseScriptExecution',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 2.0,
startTimestamp: 101.0,
endTimestamp: 103.0,
signature: 'CompileC nested.c',
subSteps: [],
},
],
},
],
},
],
};
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
}: {
tempDir?: string;
jsonOutput?: string;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
reportPath: path.join(tempDir, 'xcactivitylog.json'),
tempDir,
};
}
describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
schema: { name: 'BadProject' },
subSteps: [{ title: 123, subSteps: [] }],
}),
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('swallows cleanup failures without emitting additional logs', async () => {
const logger = createMockLogger();
mockFilesystem();
jest.spyOn(fs, 'rm').mockImplementation(async () => {
throw new Error('cleanup failed');
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
describe('isCompileStep', () => {
it('identifies cCompilation', () => {
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
});
it('identifies compileAssetsCatalog', () => {
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
});
it('identifies compileStoryboard', () => {
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
});
it('identifies SwiftCompile by signature prefix', () => {
expect(
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
).toBe(true);
});
it('identifies SwiftGeneratePch by signature prefix', () => {
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
true
);
});
it('rejects non-compile steps', () => {
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
});
it('handles missing fields gracefully', () => {
expect(isCompileStep({})).toBe(false);
});
});
describe('collectTopLevelCompileSteps', () => {
it('collects direct compile children', () => {
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(2);
});
it('finds nested compile steps through non-compile parents', () => {
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(1);
expect(result[0].detailStepType).toBe('cCompilation');
});
it('returns empty array when no compile steps', () => {
const target = FIXTURE_NO_COMPILE.subSteps[0];
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(0);
});
});
describe('buildTargetMetrics', () => {
it('returns metrics sorted by taskSeconds descending', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
expect(results.length).toBeGreaterThan(0);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
}
});
it('computes correct taskSeconds for ModuleA', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
});
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
});
it('filters out modules below minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
});
it('allows custom minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeDefined();
});
it('returns empty results for empty data', () => {
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
expect(results).toHaveLength(0);
expect(totalTaskSeconds).toBe(0);
});
it('handles missing timestamps gracefully', () => {
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
const mod = results.find(r => r.moduleName === 'PartialModule');
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(3.0);
expect(mod!.wallSpan).toBe(0); // no valid intervals
});
});
describe('formatReport', () => {
it('produces table with header and module rows', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('ModuleA');
expect(report).toContain('ModuleB');
expect(report).toContain('TOTAL');
expect(report).toContain('% Task');
});
it('does not include modules below threshold', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).not.toContain('TinyModule');
});
it('handles empty data without crashing', () => {
const report = formatReport(FIXTURE_EMPTY);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('TOTAL');
});
});
@@ -1,418 +0,0 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
}): Promise<void> {
let tempDir: string | undefined;
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
});
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
}
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
const proxiedUrl = getProxiedDownloadUrl({ directUrl, proxyBaseUrl });
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
{ err: proxiedDownloadResult.reason },
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
await cleanupXclogparserArtifacts({ tempDir, zipPath });
} else {
return proxiedDownloadResult.value;
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
return binaryPath;
}
async function cleanupXclogparserArtifacts({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<void> {
await asyncResult(fs.rm(zipPath, { force: true }));
await asyncResult(fs.rm(path.join(tempDir, 'xclogparser'), { force: true }));
}
async function runXclogparser({
binaryPath,
derivedDataPath,
workspacePath,
outputDir,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
await spawn(
binaryPath,
[
'parse',
'--derived_data',
derivedDataPath,
'--workspace',
workspacePath,
'--reporter',
'json',
'--output',
outputPath,
],
{ stdio: 'pipe' }
);
return outputPath;
}
const XcactivitylogStepSchemaZ = z.object({
title: z.string().optional(),
detailStepType: z.string().optional(),
signature: z.string().optional(),
duration: z.number().optional(),
startTimestamp: z.number().optional(),
endTimestamp: z.number().optional(),
get subSteps() {
return z.array(XcactivitylogStepSchemaZ).optional();
},
});
const XcactivitylogDataSchemaZ = z.object({
schema: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
subSteps: z.array(XcactivitylogStepSchemaZ).optional(),
});
type XcactivitylogStep = z.infer<typeof XcactivitylogStepSchemaZ>;
type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
interface TargetMetric {
moduleName: string;
taskSeconds: number;
wallSpan: number;
activeWallTime: number;
}
interface Interval {
start: number;
end: number;
}
const COMPILE_DETAIL_TYPES = new Set(['cCompilation', 'compileAssetsCatalog', 'compileStoryboard']);
const COMPILE_SIGNATURE_PREFIXES = ['SwiftCompile ', 'SwiftGeneratePch '];
function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
return true;
}
return COMPILE_SIGNATURE_PREFIXES.some(prefix => signature.startsWith(prefix));
}
export function collectTopLevelCompileSteps(
step: XcactivitylogStep,
results: XcactivitylogStep[] = []
): XcactivitylogStep[] {
for (const child of step.subSteps ?? []) {
if (isCompileStep(child)) {
results.push(child);
continue;
}
collectTopLevelCompileSteps(child, results);
}
return results;
}
function intervalFromStep(step: XcactivitylogStep): Interval | null {
if (typeof step.startTimestamp !== 'number' || typeof step.endTimestamp !== 'number') {
return null;
}
return { start: step.startTimestamp, end: step.endTimestamp };
}
function computeActiveWallTime(intervals: Interval[]): number {
if (intervals.length === 0) {
return 0;
}
const sorted = [...intervals].sort((a, b) => a.start - b.start);
let activeWallTime = 0;
let currentStart = sorted[0].start;
let currentEnd = sorted[0].end;
for (const interval of sorted.slice(1)) {
if (interval.start <= currentEnd) {
currentEnd = Math.max(currentEnd, interval.end);
continue;
}
activeWallTime += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
activeWallTime += currentEnd - currentStart;
return activeWallTime;
}
export function buildTargetMetrics(
data: XcactivitylogData,
{ minTaskSeconds = 0.5 }: { minTaskSeconds?: number } = {}
): { results: TargetMetric[]; totalTaskSeconds: number } {
const results: TargetMetric[] = [];
for (const step of data.subSteps ?? []) {
const title = step.title ?? '';
if (!title.startsWith('Build target ')) {
continue;
}
const moduleName = title.replace('Build target ', '');
const compileSteps = collectTopLevelCompileSteps(step);
const taskSeconds = compileSteps.reduce((sum, s) => sum + (s.duration ?? 0), 0);
if (taskSeconds < minTaskSeconds) {
continue;
}
const intervals = compileSteps.map(intervalFromStep).filter((i): i is Interval => i !== null);
const minStart = intervals.length > 0 ? Math.min(...intervals.map(i => i.start)) : 0;
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map(i => i.end)) : 0;
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
const activeWallTime = computeActiveWallTime(intervals);
results.push({ moduleName, taskSeconds, wallSpan, activeWallTime });
}
results.sort((a, b) => {
if (b.taskSeconds !== a.taskSeconds) {
return b.taskSeconds - a.taskSeconds;
}
if (b.wallSpan !== a.wallSpan) {
return b.wallSpan - a.wallSpan;
}
return a.moduleName.localeCompare(b.moduleName);
});
const totalTaskSeconds = results.reduce((sum, r) => sum + r.taskSeconds, 0);
return { results, totalTaskSeconds };
}
function formatSeconds(value: number): string {
return `${value.toFixed(1)}s`;
}
export function formatReport(data: XcactivitylogData): string {
const { results, totalTaskSeconds } = buildTargetMetrics(data);
const nameWidth = Math.max(6, ...results.map(r => r.moduleName.length)) + 2;
const taskWidth = 10;
const pctWidth = 8;
const wallWidth = 10;
const barMaxWidth = 20;
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const lines: string[] = [];
lines.push('');
lines.push('Xcode Build — Compile Metrics by Module');
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
lines.push('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Task'.padStart(taskWidth) +
' │ ' +
'% Task'.padStart(pctWidth) +
' │ ' +
'Wall'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const result of results) {
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
lines.push(
'│ ' +
result.moduleName.padEnd(nameWidth) +
' │ ' +
formatSeconds(result.taskSeconds).padStart(taskWidth) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.wallSpan).padStart(wallWidth) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -20,9 +20,5 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -13,7 +13,4 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
@@ -1,139 +0,0 @@
import { EasCliNpmTags } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
import { isAtLeastNpm7Async } from '../packageManager';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
isAtLeastNpm7Async: jest.fn(async () => true),
}));
describe(resolveEasCommandPrefixAndEnvAsync, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('resolves easd in development when easd --help succeeds', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 0 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'easd',
args: [],
extraEnv: {},
});
expect(spawn).toHaveBeenCalledWith(
'easd',
['--help'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
});
it('resolves staging tag in development when easd --help fails', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag in development when easd probe throws', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
process.env.ENVIRONMENT = 'staging';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
it('resolves production tag by default', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
expect(spawn).not.toHaveBeenCalled();
});
it('omits -y when npm is older than v7', async () => {
jest.mocked(isAtLeastNpm7Async).mockResolvedValueOnce(false);
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
});
});
describe(runEasCliCommand, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('merges caller env with resolved extra env', async () => {
process.env.ENVIRONMENT = 'staging';
await runEasCliCommand({
args: ['deploy', '--json'],
options: {
cwd: '/tmp/project',
env: { FOO: 'bar' },
},
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
expect.objectContaining({
cwd: '/tmp/project',
env: expect.objectContaining({
FOO: 'bar',
EXPO_STAGING: '1',
}),
})
);
});
});
@@ -1,4 +1,3 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
@@ -6,7 +5,6 @@ import path from 'path';
import semver from 'semver';
import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
@@ -25,23 +23,23 @@ describe(resolvePackageManager, () => {
});
it('returns yarn when no lockfiles exist', async () => {
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns npm when only package-json.lock exist', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
expect(resolvePackageManager(rootDir)).toBe('npm');
});
it('returns yarn when only yarn.lock exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns yarn when both lockfiles exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
expect(resolvePackageManager(rootDir)).toBe('yarn');
});
it('returns npm within a monorepo', async () => {
@@ -57,7 +55,7 @@ describe(resolvePackageManager, () => {
name: '@monorepo/expo-app',
});
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
expect(resolvePackageManager(nestedDir)).toBe('npm');
});
it('returns yarn with an invalid monorepo', async () => {
@@ -69,51 +67,7 @@ describe(resolvePackageManager, () => {
await fs.mkdirp(nestedDir);
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
});
it('returns yarn when no lockfile and env var is an empty string', () => {
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: '' },
})
).toBe(PackageManager.YARN);
});
it('returns bun from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bun' } })).toBe(
PackageManager.BUN
);
});
it('returns pnpm from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'pnpm' } })).toBe(
PackageManager.PNPM
);
});
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' }, // invalid, but should not even be read
})
).toBe(PackageManager.NPM);
});
it('throws a UserError on unsupported EAS_FALLBACK_PACKAGE_MANAGER value', () => {
expect(() =>
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } })
).toThrow(errors.UserError);
try {
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } });
} catch (e) {
expect(e).toBeInstanceOf(errors.UserError);
const error = e as errors.UserError;
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
expect(error.message).toContain('bunn');
expect(error.message).toContain('yarn, npm, pnpm, bun');
}
expect(resolvePackageManager(nestedDir)).toBe('yarn');
});
});
@@ -9,12 +9,7 @@ import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import {
isUsingModernYarnVersion,
readAndLogPackageJson,
readEasJsonContents,
runExpoCliCommand,
} from '../project';
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
@@ -153,23 +148,3 @@ describe(readEasJsonContents, () => {
expect(readEasJsonContents(projectDir)).toBe(contents);
});
});
describe(readAndLogPackageJson, () => {
beforeEach(() => {
vol.reset();
});
it('reads package.json, logs it, and returns parsed contents', async () => {
const projectDir = '/project';
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = { name: 'app', version: '1.0.0' };
const logger = { info: jest.fn() };
await fs.mkdirp(projectDir);
await fs.writeJson(packageJsonPath, packageJson);
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
});
});
-61
View File
@@ -1,61 +0,0 @@
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
import { isAtLeastNpm7Async } from './packageManager';
async function probeEasdAsync(): Promise<boolean> {
try {
const result = await spawn('easd', ['--help'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return result.status === 0;
} catch {
return false;
}
}
export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
if (await probeEasdAsync()) {
return { cmd: 'easd', args: [], extraEnv: {} };
}
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
export async function runEasCliCommand({
args,
options,
}: {
args: string[];
options: SpawnOptions;
}): Promise<SpawnResult> {
const { logger, ...spawnOptions } = options;
const { cmd, args: commandPrefixArgs, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
return await spawn(cmd, [...commandPrefixArgs, ...args], {
...spawnOptions,
logger,
env: { ...spawnOptions.env, ...extraEnv },
});
}
@@ -1,7 +1,5 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
import { z } from 'zod';
@@ -13,10 +11,7 @@ export enum PackageManager {
BUN = 'bun',
}
export function resolvePackageManager(
directory: string,
{ env }: { env: BuildStepEnv }
): PackageManager {
export function resolvePackageManager(directory: string): PackageManager {
try {
const manager = PackageManagerUtils.resolvePackageManager(directory);
if (manager === 'npm') {
@@ -25,24 +20,12 @@ export function resolvePackageManager(
return PackageManager.PNPM;
} else if (manager === 'bun') {
return PackageManager.BUN;
} else if (manager === 'yarn') {
} else {
return PackageManager.YARN;
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
} catch {
return PackageManager.YARN;
}
return PackageManager.YARN;
}
/**
-10
View File
@@ -78,16 +78,6 @@ export function readPackageJson(projectDir: string): any {
}
}
export function readAndLogPackageJson(
logger: { info: (message: string) => void },
projectDir: string
): any {
logger.info('Using package.json:');
const packageJson = readPackageJson(projectDir);
logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
}
export function readEasJsonContents(projectDir: string): string {
const easJsonPath = path.join(projectDir, 'eas.json');
if (!fs.pathExistsSync(easJsonPath)) {
-1
View File
@@ -11,7 +11,6 @@
"composite": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["jest", "node"],
"plugins": [
{
"name": "gql.tada/ts-plugin",
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.8.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -56,110 +56,6 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
@@ -188,232 +84,6 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context with app_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'ready_for_review',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect app_version state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
app_version: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with external_beta', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'beta_approved',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect external_beta state', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
external_beta: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('accepts app_store_connect context with beta_feedback', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'screenshot',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects app_store_connect beta_feedback without url', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'crash',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect beta_feedback type', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
beta_feedback: {
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
type: 'invalid-type',
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
@@ -403,7 +403,6 @@ describe('Ios.JobSchema', () => {
);
expect(value).not.toMatchObject(managedJob);
});
test('validates channel', () => {
const managedJob = {
secrets: {
+13 -68
View File
@@ -226,72 +226,6 @@ const GitHubContextZ = z.object({
.optional(),
});
const AppStoreConnectContextZ = z.looseObject({
app: z.looseObject({
id: z.string(),
}),
app_version: z
.looseObject({
id: z.string(),
state: z.enum([
'accepted',
'developer_rejected',
'in_review',
'invalid_binary',
'metadata_rejected',
'pending_apple_release',
'pending_developer_release',
'prepare_for_submission',
'processing_for_distribution',
'ready_for_distribution',
'ready_for_review',
'rejected',
'replaced_with_new_version',
'waiting_for_export_compliance',
'waiting_for_review',
]),
})
.optional(),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
cf_bundle_version: z.string().optional(),
build: z
.looseObject({
id: z.string(),
})
.optional(),
})
.optional(),
external_beta: z
.looseObject({
id: z.string(),
state: z.enum([
'processing',
'processing_exception',
'missing_export_compliance',
'ready_for_beta_testing',
'in_beta_testing',
'expired',
'ready_for_beta_submission',
'in_export_compliance_review',
'waiting_for_beta_review',
'in_beta_review',
'beta_rejected',
'beta_approved',
]),
})
.optional(),
beta_feedback: z
.looseObject({
id: z.string(),
type: z.enum(['crash', 'screenshot']),
url: z.string(),
})
.optional(),
});
export const StaticWorkflowInterpolationContextZ = z.object({
after: z.record(
z.string(),
@@ -325,8 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
app_store_connect: AppStoreConnectContextZ.optional(),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
})
.optional(),
})
.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
-3
View File
@@ -41,7 +41,6 @@ export enum BuildPhase {
INSTALL_PODS = 'INSTALL_PODS',
CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT',
RUN_FASTLANE = 'RUN_FASTLANE',
PARSE_XCACTIVITYLOG = 'PARSE_XCACTIVITYLOG',
// HOOKS
PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK',
@@ -109,7 +108,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'Install pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project',
[BuildPhase.RUN_FASTLANE]: 'Run fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'Analyze build performance',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook',
@@ -174,7 +172,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'install-pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project',
[BuildPhase.RUN_FASTLANE]: 'run-fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'parse-xcactivitylog',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook',
+1 -1
View File
@@ -50,7 +50,7 @@ yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from the API URL resolved by `src/api.ts` and generates:
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import { getExpoApiBaseUrl } from './src/api';
const schema =
process.env.GRAPHQL_SCHEMA_URL ?? new URL('/graphql', getExpoApiBaseUrl()).toString();
const config: CodegenConfig = {
overwrite: true,
schema,
documents: ['src/**/!(*.d).ts'],
generates: {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
dedupeOperationSuffix: true,
},
hooks: {
afterOneFileWrite: ['node ./scripts/annotate-graphql-codegen.js'],
},
},
'./graphql.schema.json': {
plugins: ['introspection'],
},
},
};
export default config;
+17
View File
@@ -0,0 +1,17 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
./graphql.schema.json:
plugins:
- 'introspection'
+445 -6131
View File
File diff suppressed because it is too large. Load diff
+5 -11
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.11.0",
"version": "18.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -37,7 +37,7 @@
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
@@ -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 third-party service integrations"
},
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
+116
View File
@@ -800,6 +800,118 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"dataUsage": {
"$ref": "#/definitions/apple/AppleDataUsage",
"description": "App Privacy Nutrition Labels. Declares what data the app collects, how it is used, and how it is linked to the user. Required for new app submissions since 2021."
}
}
},
"AppleDataUsage": {
"type": "object",
"additionalProperties": false,
"description": "Privacy Nutrition Labels — declarative declaration of data the app collects. When `dataNotCollected` is true, no data is collected and `categories` must be empty.",
"properties": {
"dataNotCollected": {
"type": "boolean",
"description": "Whether the app collects no data at all. Mirrors Apple's \"Data Not Collected\" toggle. When true, `categories` must be empty."
},
"categories": {
"type": "array",
"description": "Per-category data usage declarations.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryEntry"
}
}
}
},
"AppleDataUsageCategoryEntry": {
"type": "object",
"additionalProperties": false,
"required": ["category"],
"description": "A single Privacy Nutrition Labels declaration: pairs a data category with the purposes it is used for and the data protection / linkage classification.",
"properties": {
"category": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryId",
"description": "Apple data category."
},
"purposes": {
"type": "array",
"description": "Purposes the category is used for. At least one entry is required when the category is collected.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsagePurposeId"
}
},
"protections": {
"type": "array",
"description": "Data protection / linkage classification(s). A single category can be both linked to the user and used for tracking.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageDataProtectionId"
}
}
}
},
"AppleDataUsageCategoryId": {
"enum": [
"ADVERTISING_DATA",
"AUDIO",
"BROWSING_HISTORY",
"COARSE_LOCATION",
"CONTACTS",
"CRASH_DATA",
"CREDIT_AND_FRAUD",
"CUSTOMER_SUPPORT",
"DEVICE_ID",
"EMAIL_ADDRESS",
"EMAILS_OR_TEXT_MESSAGES",
"ENVIRONMENTAL_SCANNING",
"FITNESS",
"GAMEPLAY_CONTENT",
"HANDS",
"HEAD_MOVEMENT",
"HEALTH",
"NAME",
"OTHER_CONTACT_INFO",
"OTHER_DATA",
"OTHER_DIAGNOSTIC_DATA",
"OTHER_FINANCIAL_INFO",
"OTHER_USAGE_DATA",
"OTHER_USER_CONTENT",
"PAYMENT_INFORMATION",
"PERFORMANCE_DATA",
"PHONE_NUMBER",
"PHOTOS_OR_VIDEOS",
"PHYSICAL_ADDRESS",
"PRECISE_LOCATION",
"PRODUCT_INTERACTION",
"PURCHASE_HISTORY",
"SEARCH_HISTORY",
"SENSITIVE_INFO",
"USER_ID"
]
},
"AppleDataUsagePurposeId": {
"enum": [
"THIRD_PARTY_ADVERTISING",
"DEVELOPERS_ADVERTISING",
"ANALYTICS",
"PRODUCT_PERSONALIZATION",
"APP_FUNCTIONALITY",
"OTHER_PURPOSES"
]
},
"AppleDataUsageDataProtectionId": {
"enum": [
"DATA_USED_TO_TRACK_YOU",
"DATA_LINKED_TO_YOU",
"DATA_NOT_LINKED_TO_YOU",
"DATA_NOT_COLLECTED"
]
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1034,6 +1146,10 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
-346
View File
@@ -1,346 +0,0 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
/*
* Manual Convex integration E2E runner.
*
* Run from packages/eas-cli with:
* yarn ts-node scripts/convex-e2e.ts
*
* This uses the deployed EAS API and real authenticated EAS/Convex-backed
* resources. Cleanup removes EAS-side links when possible, but it does not
* destroy Convex-side resources.
*
* Options:
* CONVEX_E2E_KEEP_PROJECT=1 keeps the temp Expo project and skips cleanup.
* CONVEX_E2E_SKIP_DASHBOARD=1 skips opening the Convex dashboard.
* CONVEX_E2E_ACCOUNT=<account-name> sets the Expo app owner before eas init.
*/
type RunOptions = {
cwd?: string;
allowFailure?: boolean;
env?: NodeJS.ProcessEnv;
};
type RunResult = {
stdout: string;
stderr: string;
exitCode: number | null;
};
const KEEP_PROJECT = process.env.CONVEX_E2E_KEEP_PROJECT === '1';
const SKIP_DASHBOARD = process.env.CONVEX_E2E_SKIP_DASHBOARD === '1';
const ACCOUNT = process.env.CONVEX_E2E_ACCOUNT;
const SCRIPT_DIR = __dirname;
const EAS_CLI_ROOT = path.resolve(SCRIPT_DIR, '..');
const LOCAL_EAS_BIN = path.join(EAS_CLI_ROOT, 'bin', 'run');
const TEMP_ROOT_PREFIX = path.join(os.tmpdir(), 'eas-cli-convex-e2e-');
let tempRoot: string | null = null;
let projectDir: string | null = null;
let initializedProject = false;
let initialConvexTeamSlugs: string[] = [];
let createdConvexTeamSlug: string | null = null;
async function mainAsync(): Promise<void> {
try {
await assertFileExistsAsync(
LOCAL_EAS_BIN,
`Local EAS CLI binary not found at ${LOCAL_EAS_BIN}`
);
await runEasAsync(['whoami']);
const testName = `convex-e2e-${Date.now().toString(36)}`;
tempRoot = await fs.mkdtemp(TEMP_ROOT_PREFIX);
projectDir = path.join(tempRoot, testName);
logStep(`Creating fresh Expo project ${testName}`);
await runAsync('npx', ['--yes', 'create-expo-app@latest', projectDir, '--template', 'blank'], {
cwd: tempRoot,
env: getNpxEnvironment(),
});
await updateAppJsonAsync(projectDir, testName);
logStep('Creating/linking EAS project');
await runEasAsync(['init', '--non-interactive', '--force'], projectDir);
initializedProject = true;
initialConvexTeamSlugs = parseConvexTeamSlugs(
(await runEasAsync(['integrations:convex:team'], projectDir)).stdout
);
logStep('Connecting Convex');
await runEasAsync(
['integrations:convex:connect', '--non-interactive', '--project-name', testName],
projectDir
);
await assertEnvLocalAsync(projectDir);
await assertPackageJsonDependencyAsync(projectDir, 'convex');
const teamOutput = await runEasAsync(['integrations:convex:team'], projectDir);
const projectOutput = await runEasAsync(['integrations:convex:project'], projectDir);
assertIncludes(
projectOutput.stdout,
'Dashboard',
'Expected project output to include dashboard'
);
const teamSlugsAfterConnect = parseConvexTeamSlugs(teamOutput.stdout);
const newTeamSlugs = teamSlugsAfterConnect.filter(
slug => !initialConvexTeamSlugs.includes(slug)
);
createdConvexTeamSlug = newTeamSlugs[0] ?? null;
const convexTeamSlug = createdConvexTeamSlug ?? teamSlugsAfterConnect[0];
if (!convexTeamSlug) {
throw new Error('Could not determine a Convex team slug from team output.');
}
await runEasAsync(
['integrations:convex:team:invite', convexTeamSlug, '--non-interactive'],
projectDir
);
if (SKIP_DASHBOARD) {
logStep('Skipping dashboard command because CONVEX_E2E_SKIP_DASHBOARD=1');
} else {
await runEasAsync(['integrations:convex:dashboard'], projectDir);
}
logStep('Convex E2E flow completed successfully');
} finally {
await cleanupAsync();
}
}
async function cleanupAsync(): Promise<void> {
if (!projectDir || !tempRoot) {
return;
}
if (KEEP_PROJECT) {
logStep('Skipping cleanup because CONVEX_E2E_KEEP_PROJECT=1');
log(`Temp project kept at: ${projectDir}`);
log('EAS-side links and Convex-side resources were left intact.');
return;
}
if (initializedProject) {
logStep('Cleaning up EAS-side Convex links');
const convexTeamSlugToDelete =
createdConvexTeamSlug ?? (await getCreatedConvexTeamSlugForCleanupAsync());
await runCleanupCommandAsync([
'integrations:convex:project:delete',
'--non-interactive',
'--yes',
]);
if (convexTeamSlugToDelete) {
await runCleanupCommandAsync([
'integrations:convex:team:delete',
convexTeamSlugToDelete,
'--non-interactive',
'--yes',
]);
} else {
warn(
'No newly-created Convex team link was detected. Skipping team link deletion to avoid removing a pre-existing EAS-side link.'
);
}
warn('Convex-side resources are not destroyed by this cleanup.');
}
await fs.rm(tempRoot, { recursive: true, force: true });
log(`Removed temp project directory: ${tempRoot}`);
}
async function getCreatedConvexTeamSlugForCleanupAsync(): Promise<string | null> {
if (!projectDir) {
return null;
}
const result = await runEasAsync(['integrations:convex:team'], projectDir, {
allowFailure: true,
});
if (result.exitCode !== 0) {
warn('Could not inspect Convex team links during cleanup.');
return null;
}
return (
parseConvexTeamSlugs(result.stdout).find(slug => !initialConvexTeamSlugs.includes(slug)) ?? null
);
}
async function runCleanupCommandAsync(args: string[]): Promise<void> {
try {
const result = await runEasAsync(args, projectDir ?? undefined, { allowFailure: true });
if (result.exitCode !== 0) {
warn(`Cleanup command exited with ${result.exitCode}: ${args.join(' ')}`);
}
} catch (error) {
warn(`Cleanup command failed: ${args.join(' ')}`);
warn(error instanceof Error ? error.message : String(error));
}
}
async function runEasAsync(
args: string[],
cwd?: string,
options: RunOptions = {}
): Promise<RunResult> {
return await runAsync(LOCAL_EAS_BIN, args, { cwd, ...options });
}
async function runAsync(
command: string,
args: string[],
{ cwd, allowFailure = false, env = {} }: RunOptions = {}
): Promise<RunResult> {
const printableCommand = [command, ...args].join(' ');
log(`\n$ ${printableCommand}`);
if (cwd) {
log(`cwd: ${cwd}`);
}
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
const chunk = data.toString();
stdout += chunk;
process.stdout.write(redact(chunk));
});
child.stderr.on('data', data => {
const chunk = data.toString();
stderr += chunk;
process.stderr.write(redact(chunk));
});
child.on('error', reject);
child.on('close', exitCode => {
const result = { stdout, stderr, exitCode };
if (!allowFailure && exitCode !== 0) {
reject(new Error(`Command failed with exit code ${exitCode}: ${printableCommand}`));
return;
}
resolve(result);
});
});
}
async function updateAppJsonAsync(appDir: string, testName: string): Promise<void> {
const appJsonPath = path.join(appDir, 'app.json');
const rawAppJson = await fs.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(rawAppJson);
appJson.expo = {
...appJson.expo,
name: testName,
slug: testName,
...(ACCOUNT ? { owner: ACCOUNT } : {}),
};
await fs.writeFile(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`);
}
async function assertEnvLocalAsync(appDir: string): Promise<void> {
const envLocalPath = path.join(appDir, '.env.local');
const envLocal = await fs.readFile(envLocalPath, 'utf8');
assertIncludes(
envLocal,
'CONVEX_DEPLOY_KEY=',
'Expected .env.local to contain CONVEX_DEPLOY_KEY'
);
assertIncludes(
envLocal,
'EXPO_PUBLIC_CONVEX_URL=',
'Expected .env.local to contain EXPO_PUBLIC_CONVEX_URL'
);
log('\nVerified .env.local contains Convex environment variables:');
log(redact(envLocal));
}
async function assertPackageJsonDependencyAsync(
appDir: string,
packageName: string
): Promise<void> {
const packageJsonPath = path.join(appDir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (!dependencies[packageName]) {
throw new Error(`Expected package.json to include ${packageName}`);
}
log(`Verified package.json includes ${packageName}@${dependencies[packageName]}`);
}
function parseConvexTeamSlugs(output: string): string[] {
const strippedOutput = stripAnsi(output);
return Array.from(strippedOutput.matchAll(/^Team:\s+.+?\s\/\s(.+)$/gm), match => match[1].trim());
}
function assertIncludes(value: string, expected: string, message: string): void {
if (!value.includes(expected)) {
throw new Error(message);
}
}
async function assertFileExistsAsync(filePath: string, message: string): Promise<void> {
try {
await fs.access(filePath);
} catch {
throw new Error(message);
}
}
function logStep(message: string): void {
log(`\n==> ${message}`);
}
function log(message: string): void {
process.stdout.write(`${message}\n`);
}
function warn(message: string): void {
process.stderr.write(`${message}\n`);
}
function redact(value: string): string {
return value
.replace(/^CONVEX_DEPLOY_KEY=.*$/gm, 'CONVEX_DEPLOY_KEY=<redacted>')
.replace(/dev:[^\s|]+\|[^\s]+/g, 'dev:<redacted>');
}
function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-9;]*m/g, '');
}
function getNpxEnvironment(): NodeJS.ProcessEnv {
return {
npm_config_user_agent: `npm/10 node/${process.versions.node} ${process.platform} ${process.arch}`,
npm_execpath: undefined,
};
}
mainAsync().catch(error => {
warn(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -1,81 +0,0 @@
import { vol } from 'memfs';
import { ensureLockfileExistsAsync } from '../validateLockfile';
jest.mock('fs');
const mockResolveWorkspaceRoot = jest.fn();
jest.mock('@expo/package-manager', () => {
const actual = jest.requireActual('@expo/package-manager');
return {
...actual,
resolveWorkspaceRoot: (...args: unknown[]) => mockResolveWorkspaceRoot(...args),
};
});
beforeEach(() => {
vol.reset();
mockResolveWorkspaceRoot.mockReturnValue(null);
});
describe(ensureLockfileExistsAsync, () => {
it('passes when package-lock.json exists', async () => {
vol.fromJSON({ './package-lock.json': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('passes when yarn.lock exists', async () => {
vol.fromJSON({ './yarn.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('passes when pnpm-lock.yaml exists', async () => {
vol.fromJSON({ './pnpm-lock.yaml': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('passes when bun.lockb exists', async () => {
vol.fromJSON({ './bun.lockb': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('passes when bun.lock exists', async () => {
vol.fromJSON({ './bun.lock': '' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).resolves.not.toThrow();
});
it('throws when no lockfile exists', async () => {
vol.fromJSON({ './package.json': '{}' }, '/app');
await expect(ensureLockfileExistsAsync('/app')).rejects.toThrow(
'No lockfile found in the project directory.'
);
});
it('passes when lockfile exists in workspace root', async () => {
vol.fromJSON(
{
'./packages/my-app/package.json': '{}',
'./yarn.lock': '',
},
'/monorepo'
);
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
await expect(
ensureLockfileExistsAsync('/monorepo/packages/my-app')
).resolves.not.toThrow();
});
it('throws when no lockfile in project dir or workspace root', async () => {
vol.fromJSON(
{
'./packages/my-app/package.json': '{}',
'./package.json': '{}',
},
'/monorepo'
);
mockResolveWorkspaceRoot.mockReturnValue('/monorepo');
await expect(ensureLockfileExistsAsync('/monorepo/packages/my-app')).rejects.toThrow(
'No lockfile found in the project directory.'
);
});
});
@@ -22,7 +22,6 @@ import { createBuildContextAsync } from './createContext';
import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureLockfileExistsAsync } from './validateLockfile';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
@@ -66,7 +65,6 @@ import {
} from '../project/remoteVersionSource';
import { confirmAsync } from '../prompts';
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run';
import { SimulatorRunTarget } from '../run/ios/run';
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
import { createSubmissionContextAsync } from '../submit/context';
import {
@@ -86,7 +84,6 @@ import { Client } from '../vcs/vcs';
let metroConfigValidated = false;
let sdkVersionChecked = false;
let lockfileChecked = false;
let hasWarnedAboutUsageOverages = false;
export interface BuildFlags {
@@ -105,7 +102,6 @@ export interface BuildFlags {
freezeCredentials: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
}
export async function runBuildAndSubmitAsync({
@@ -418,13 +414,6 @@ async function prepareAndStartBuildAsync({
env,
});
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
if (!process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
await ensureLockfileExistsAsync(projectDir);
}
lockfileChecked = true;
}
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
hasWarnedAboutUsageOverages = true;
Log.newLine();
@@ -590,17 +579,14 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
}
}
export async function downloadAndRunAsync(
build: BuildFragment,
simulator?: SimulatorRunTarget
): Promise<void> {
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
if (await pathExists(cachedAppPath)) {
Log.newLine();
Log.log(`Using cached app...`);
await runAsync(cachedAppPath, build.platform, simulator);
await runAsync(cachedAppPath, build.platform);
return;
}
@@ -609,7 +595,7 @@ export async function downloadAndRunAsync(
build.platform,
cachedAppPath
);
await runAsync(buildPath, build.platform, simulator);
await runAsync(buildPath, build.platform);
}
async function maybeDownloadAndRunSimulatorBuildsAsync(
@@ -631,9 +617,9 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
} build on ${
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
}?`,
}));
}));
if (confirm) {
await downloadAndRunAsync(simBuild, flags.simulator);
await downloadAndRunAsync(simBuild);
}
}
}
@@ -1,75 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
import * as tar from 'tar';
import { Client } from '../../../vcs/vcs';
import { makeProjectTarballAsync } from '../repository';
jest.mock('../../../ora', () => ({
ora: jest.fn(() => ({
fail: jest.fn(),
isSpinning: false,
start: jest.fn(),
succeed: jest.fn(),
})),
}));
class FakeVcsClient extends Client {
public async makeShallowCopyAsync(destinationPath: string): Promise<void> {
await fs.mkdirp(path.join(destinationPath, 'bin'));
await fs.mkdirp(path.join(destinationPath, 'read-only-dir'));
await fs.writeFile(path.join(destinationPath, 'bin', 'postcheckout.sh'), '#!/bin/sh\necho hi\n');
await fs.writeFile(path.join(destinationPath, 'regular-file.txt'), 'regular file\n');
await fs.writeFile(path.join(destinationPath, 'read-only-dir', 'child.txt'), 'child file\n');
await fs.chmod(path.join(destinationPath, 'bin', 'postcheckout.sh'), 0o755);
await fs.chmod(path.join(destinationPath, 'regular-file.txt'), 0o644);
await fs.chmod(path.join(destinationPath, 'read-only-dir'), 0o555);
}
public async getRootPathAsync(): Promise<string> {
return process.cwd();
}
public canGetLastCommitMessage(): boolean {
return false;
}
}
describe(makeProjectTarballAsync, () => {
it('creates portable project archives while preserving executable files', async () => {
const removeAsync = fs.remove.bind(fs);
const removeSpy = jest.spyOn(fs, 'remove').mockImplementation(async targetPath => {
await fs.chmod(path.join(targetPath.toString(), 'read-only-dir'), 0o755).catch(() => {});
await removeAsync(targetPath);
});
const projectTarball = await makeProjectTarballAsync(new FakeVcsClient());
const entries = new Map<string, tar.ReadEntry>();
try {
await tar.list({
file: projectTarball.path,
onentry: entry => {
entries.set(entry.path, entry);
},
});
expect(entries.get('project/bin/postcheckout.sh')?.mode).toBe(0o755);
expect(entries.get('project/regular-file.txt')?.mode).toBe(0o644);
expect(entries.get('project/read-only-dir/')?.mode).toBe(0o755);
expect(entries.has('project/read-only-dir/child.txt')).toBe(true);
for (const entry of entries.values()) {
expect(entry.path.startsWith('project/')).toBe(true);
expect(entry.uid).toBeUndefined();
expect(entry.gid).toBeUndefined();
expect(entry.uname).toBe('');
expect(entry.gname).toBe('');
}
} finally {
await fs.remove(projectTarball.path);
removeSpy.mockRestore();
}
});
});
+3 -18
View File
@@ -143,24 +143,9 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
try {
await vcsClient.makeShallowCopyAsync(shallowClonePath);
await tar.create(
{
cwd: shallowClonePath,
file: tarPath,
prefix: 'project',
gzip: true,
portable: true,
onWriteEntry(entry) {
// Read-only directories may have files inside them. This causes trouble on tar extraction.
// Hence, we're forcing the owner write bit on directories in Windows
// to avoid this issue.
if (entry.type === 'Directory' && entry.stat) {
entry.stat.mode |= 0o200;
}
},
},
['.']
);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
} catch (err) {
clearTimeout(timer);
if (spinner.isSpinning) {
-18
View File
@@ -64,24 +64,6 @@ export function getWorkflowRunUrl(
).toString();
}
/**
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
* or the feature-specific dashboard) that gives users more context. Use this only for internal
* tooling where no richer URL exists.
*/
export function getBareJobRunUrl(
accountName: string,
projectName: string,
jobRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/job-runs/${encodeURIComponent(jobRunId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
@@ -1,47 +0,0 @@
import {
BUN_LOCK_FILE,
BUN_TEXT_LOCK_FILE,
NPM_LOCK_FILE,
PNPM_LOCK_FILE,
YARN_LOCK_FILE,
resolveWorkspaceRoot,
} from '@expo/package-manager';
import { pathExists } from 'fs-extra';
import path from 'path';
const LOCKFILE_NAMES = [
NPM_LOCK_FILE,
YARN_LOCK_FILE,
PNPM_LOCK_FILE,
BUN_LOCK_FILE,
BUN_TEXT_LOCK_FILE,
];
async function hasLockfileAsync(dir: string): Promise<boolean> {
for (const lockfile of LOCKFILE_NAMES) {
if (await pathExists(path.join(dir, lockfile))) {
return true;
}
}
return false;
}
export async function ensureLockfileExistsAsync(projectDir: string): Promise<void> {
if (await hasLockfileAsync(projectDir)) {
return;
}
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot && workspaceRoot !== projectDir) {
if (await hasLockfileAsync(workspaceRoot)) {
return;
}
}
throw new Error(
`No lockfile found in the project directory.\n` +
`A lockfile is required to ensure deterministic dependency installation in EAS.\n` +
`Run your package manager's install command (e.g. "npm install") to generate one.\n` +
`To skip this check, run this command with EAS_BUILD_SKIP_LOCKFILE_CHECK=1.`
);
}
@@ -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 ?? '',
])
);
}
@@ -1,137 +0,0 @@
import dateFormat from 'dateformat';
import {
confirmRecentConvexInviteAsync,
formatConvexProject,
formatConvexTeam,
formatConvexTeamConnection,
getConvexProjectDashboardUrl,
getConvexTeamDashboardUrl,
} from '../convex';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../graphql/types/ConvexTeamConnection';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
jest.mock('../../log');
jest.mock('../../prompts');
describe('Convex command utilities', () => {
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
hasBeenClaimed: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('builds encoded Convex dashboard URLs', () => {
expect(getConvexTeamDashboardUrl(mockConnection)).toBe(
'https://dashboard.convex.dev/t/test%20team'
);
expect(getConvexProjectDashboardUrl(mockProject)).toBe(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('formats team and project metadata with invite details', () => {
expect(formatConvexTeam(mockConnection)).toBe('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
expect(formatConvexTeamConnection(mockConnection)).toContain('Claimed');
expect(formatConvexTeamConnection(mockConnection)).toContain('Yes');
expect(formatConvexTeamConnection(mockConnection)).toContain('[email protected]');
expect(formatConvexTeamConnection(mockConnection)).toContain(
dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss')
);
expect(formatConvexProject(mockProject)).toContain('project-123');
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
expect(formatConvexProject(mockProject)).not.toContain('team-123');
});
it('does not prompt when the previous invite timestamp is invalid', async () => {
await expect(
confirmRecentConvexInviteAsync(
{ ...mockConnection, invitedAt: 'not-a-date' },
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt when the previous invite is old', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts before resending a recent invite in interactive mode', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const recentInviteTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: recentInviteTimestamp,
},
{ nonInteractive: false }
)
).resolves.toBe(false);
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining(dateFormat(recentInviteTimestamp, 'mmm dd HH:MM:ss')),
});
});
it('warns and proceeds for recent invites in non-interactive mode', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: true }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
});
});
@@ -1,20 +0,0 @@
export function preprocessBuildCommandArgs(argv: string[]): string[] {
return withOptionalSimulatorValue(argv);
}
// oclif does not support string flags with optional values. We want "--simulator"
// to prompt for a simulator, while still supporting "--simulator <name-or-udid>".
// Insert an empty string value for the bare flag before oclif parses argv.
function withOptionalSimulatorValue(argv: string[]): string[] {
const simulatorFlagIndex = argv.indexOf('--simulator');
const simulatorValue = argv[simulatorFlagIndex + 1];
if (
simulatorFlagIndex === -1 ||
(simulatorValue !== undefined && !simulatorValue.startsWith('-'))
) {
return argv;
}
return [...argv.slice(0, simulatorFlagIndex + 1), '', ...argv.slice(simulatorFlagIndex + 1)];
}
@@ -1,89 +0,0 @@
import chalk from 'chalk';
import dateFormat from 'dateformat';
import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
import Log, { link } from '../log';
import { confirmAsync } from '../prompts';
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;
export function formatConvexInviteTimestamp(timestamp: string): string {
return dateFormat(timestamp, 'mmm dd HH:MM:ss');
}
export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
}
export function getConvexProjectDashboardUrl(project: ConvexProjectData): string {
return `${getConvexTeamDashboardUrl(project.convexTeamConnection)}/${encodeURIComponent(
project.convexProjectSlug
)}`;
}
export function formatConvexTeam(connection: ConvexTeamConnectionData): string {
return `${connection.convexTeamName} / ${connection.convexTeamSlug}`;
}
export function formatConvexTeamConnection(connection: ConvexTeamConnectionData): string {
const lines = [
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`,
];
if (connection.invitedEmail) {
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
lines.push(`${chalk.bold('Invited at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
}
return lines.join('\n');
}
export function formatConvexProject(project: ConvexProjectData): string {
return [
`${chalk.bold('Name')}: ${project.convexProjectName}`,
`${chalk.bold('Slug')}: ${project.convexProjectSlug}`,
`${chalk.bold('Identifier')}: ${project.convexProjectIdentifier}`,
`${chalk.bold('Team')}: ${formatConvexTeam(project.convexTeamConnection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoConvexTeams(accountName: string): void {
Log.warn(`No Convex team is linked to account ${chalk.bold(accountName)} on EAS.`);
}
export function logNoConvexProject(projectName: string): void {
Log.warn(`No Convex project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export async function confirmRecentConvexInviteAsync(
connection: ConvexTeamConnectionData,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<boolean> {
const invitedAt = connection.invitedAt ? new Date(connection.invitedAt) : null;
if (!invitedAt || Number.isNaN(invitedAt.getTime())) {
return true;
}
const timeSinceInviteMs = Date.now() - invitedAt.getTime();
if (timeSinceInviteMs >= RECENT_INVITE_THRESHOLD_MS) {
return true;
}
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${formatConvexInviteTimestamp(connection.invitedAt)}.`;
if (nonInteractive) {
Log.warn(
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
);
return true;
}
return await confirmAsync({
message: `${previousInvite} Are you sure you want to send another invite?`,
});
}
@@ -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,207 +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 build by ID when --id alias is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--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',
});
});
});
+4 -23
View File
@@ -12,7 +12,6 @@ import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEn
import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createFingerprintAsync } from '../../fingerprint/cli';
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
@@ -48,15 +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,
}),
simulator: Flags.string({
description:
'iOS simulator name or UDID to install and run the development build on. If no value is provided, you will be prompted to select a simulator.',
multiple: false,
}),
};
static override contextDefinition = {
@@ -69,7 +59,7 @@ export default class BuildDev extends EasCommand {
};
protected override async runAsync(): Promise<any> {
const { flags } = await this.parse(BuildDev, preprocessBuildCommandArgs(this.argv));
const { flags } = await this.parse(BuildDev);
const {
loggedIn: { actor, graphqlClient },
@@ -87,10 +77,6 @@ export default class BuildDev extends EasCommand {
if (process.platform !== 'darwin' && platform === Platform.IOS) {
Errors.error('Running iOS builds in simulator is only supported on macOS.', { exit: 1 });
}
if (flags.simulator && platform !== Platform.IOS) {
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
}
const simulator = flags.simulator === '' ? true : flags.simulator;
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
@@ -142,10 +128,8 @@ export default class BuildDev extends EasCommand {
);
if (build.artifacts?.applicationArchiveUrl) {
await downloadAndRunAsync(build, simulator);
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
}
await downloadAndRunAsync(build);
await this.startDevServerAsync({ projectDir, platform });
return;
} else {
Log.warn('Artifacts for this build expired.');
@@ -192,16 +176,13 @@ export default class BuildDev extends EasCommand {
autoSubmit: false,
localBuildOptions: {},
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
simulator,
},
actor,
getDynamicPrivateProjectConfigAsync,
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> {
+19 -165
View File
@@ -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,44 +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({
aliases: ['id'],
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,
};
@@ -66,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,
@@ -91,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,
@@ -224,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}`
);
}
@@ -239,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;
@@ -283,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;
}
@@ -215,6 +215,7 @@ export default class BuildResign extends EasCommand {
},
},
secrets: prepareCredentialsToResign(credentialsResult.credentials),
builderEnvironment: { image: 'default' },
};
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
buildId: build.id,
+3 -16
View File
@@ -4,7 +4,6 @@ import { pathExists } from 'fs-extra';
import { getLatestBuildAsync, listAndSelectBuildOnAppAsync } from '../../build/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { preprocessBuildCommandArgs } from '../../commandUtils/buildFlags';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasPaginatedQueryFlags,
@@ -18,7 +17,6 @@ import { appPlatformDisplayNames } from '../../platform';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { RunArchiveFlags, getEasBuildRunCachedAppPath, runAsync } from '../../run/run';
import { SimulatorRunTarget } from '../../run/ios/run';
import { isRunnableOnSimulatorOrEmulator } from '../../run/utils';
import {
downloadAndMaybeExtractAppAsync,
@@ -34,7 +32,6 @@ interface RawRunFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: string;
}
interface RunCommandFlags {
@@ -43,7 +40,6 @@ interface RunCommandFlags {
limit?: number;
offset?: number;
profile?: string;
simulator?: SimulatorRunTarget;
}
export default class Run extends EasCommand {
@@ -76,11 +72,6 @@ export default class Run extends EasCommand {
'Name of the build profile used to create the build to run. When specified, only builds created with the specified build profile will be queried.',
helpValue: 'PROFILE_NAME',
}),
simulator: Flags.string({
description:
'iOS simulator name or UDID to install and run the build on. If no value is provided, you will be prompted to select a simulator.',
multiple: false,
}),
...EasPaginatedQueryFlags,
};
@@ -91,7 +82,7 @@ export default class Run extends EasCommand {
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Run, preprocessBuildCommandArgs(this.argv));
const { flags: rawFlags } = await this.parse(Run);
const flags = await this.sanitizeFlagsAsync(rawFlags);
const queryOptions = getPaginatedQueryOptions(flags);
const {
@@ -108,11 +99,11 @@ export default class Run extends EasCommand {
queryOptions
);
await runAsync(simulatorBuildPath, flags.selectedPlatform, flags.simulator);
await runAsync(simulatorBuildPath, flags.selectedPlatform);
}
private async sanitizeFlagsAsync(flags: RawRunFlags): Promise<RunCommandFlags> {
const { platform, limit, offset, profile, simulator, ...runArchiveFlags } = flags;
const { platform, limit, offset, profile, ...runArchiveFlags } = flags;
const selectedPlatform = await resolvePlatformAsync(platform);
@@ -121,9 +112,6 @@ export default class Run extends EasCommand {
exit: 1,
});
}
if (simulator && selectedPlatform !== AppPlatform.Ios) {
Errors.error('The --simulator flag can only be used with --platform ios.', { exit: 1 });
}
if (
runArchiveFlags.path &&
@@ -149,7 +137,6 @@ export default class Run extends EasCommand {
limit,
offset,
profile,
simulator: simulator === '' ? true : simulator,
};
}
}
@@ -1,118 +0,0 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ChannelInsightsQuery } from '../../../graphql/queries/ChannelInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelInsights from '../insights';
jest.mock('../../../graphql/queries/ChannelInsightsQuery', () => ({
ChannelInsightsQuery: { viewChannelRuntimeInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewChannelRuntimeInsightsAsync = jest.mocked(
ChannelInsightsQuery.viewChannelRuntimeInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const insightsResponse = {
__typename: 'UpdateChannelRuntimeInsights' as const,
id: 'rti',
embeddedUpdateTotalUniqueUsers: 50,
mostPopularUpdates: [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
],
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'd1', label: 'iOS', data: [50] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'cd1', label: 'Launches', data: [42] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint' as const, id: 'launches', label: 'Launches', data: 42 },
],
},
};
describe(ChannelInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'project-1';
beforeEach(() => {
jest.clearAllMocks();
mockViewChannelRuntimeInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): ChannelInsights {
const command = new ChannelInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('passes appId, channel, runtimeVersion and default 7-day timespan to the query', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--channel', 'production', '--runtime-version', '1.0.0']);
await command.runAsync();
expect(mockViewChannelRuntimeInsightsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
channelName: 'production',
runtimeVersion: '1.0.0',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
});
jest.useRealTimers();
});
it('emits JSON when --json is passed', async () => {
const command = createCommand([
'--channel',
'production',
'--runtime-version',
'1.0.0',
'--json',
'--non-interactive',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
channel: 'production',
runtimeVersion: '1.0.0',
embeddedUpdateTotalUniqueUsers: 50,
})
);
});
it('requires --channel and --runtime-version', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
});
});
@@ -1,88 +0,0 @@
import { Flags } from '@oclif/core';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../../channel/insights/formatInsights';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelInsights extends EasCommand {
static override description =
'display adoption, crash, and unique-user insights for a channel + runtime version';
static override flags = {
channel: Flags.string({
description: 'Name of the channel.',
required: true,
}),
'runtime-version': Flags.string({
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const insights = await ChannelInsightsQuery.viewChannelRuntimeInsightsAsync(graphqlClient, {
appId: projectId,
channelName: flags.channel,
runtimeVersion: flags['runtime-version'],
startTime,
endTime,
});
const summary = toChannelInsightsSummary(flags.channel, flags['runtime-version'], insights, {
startTime,
endTime,
daysBack,
});
if (json) {
printJsonOnlyOutput(buildChannelInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildChannelInsightsTable(summary));
}
}
}
+2 -16
View File
@@ -362,22 +362,8 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
let uploadError: unknown;
try {
await uploadAssetsAsync(assetFiles, deployResult);
} catch (error) {
uploadError = error;
}
try {
await finalizeDeployAsync(deployResult);
} catch (finalizeError) {
if (!uploadError) {
throw finalizeError;
}
}
if (uploadError) {
throw uploadError;
}
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
if (flags.aliasName) {
@@ -1,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));
}
}
}
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more