mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7fad4a13 | ||
|
|
7226c72122 | ||
|
|
ae6e0fd7f6 | ||
|
|
d070f73bc5 | ||
|
|
325bb29b57 | ||
|
|
82a8e3c2d0 | ||
|
|
ec310dd115 | ||
|
|
84b09c4a28 | ||
|
|
d9338799a0 | ||
|
|
b22d89e41f | ||
|
|
81fe0f237f | ||
|
|
b9ddc04cf7 | ||
|
|
d6a8a4f694 | ||
|
|
7f163e3e52 | ||
|
|
f821cb176c | ||
|
|
3a70a5d9b8 | ||
|
|
4d74298bb7 | ||
|
|
3155fc26e7 | ||
|
|
0fb6dfc334 | ||
|
|
dd84646768 | ||
|
|
c3fbf63a71 | ||
|
|
7c0d7b60c7 | ||
|
|
5a8e1b4ba3 | ||
|
|
12b648a603 | ||
|
|
8c170af829 | ||
|
|
ad4c5299e3 | ||
|
|
dee07ae0a2 | ||
|
|
4ad12340f2 | ||
|
|
c6d4faefe7 | ||
|
|
2f8712a0f3 | ||
|
|
a439721054 | ||
|
|
2e2dddb59a | ||
|
|
b7df3ff4f8 | ||
|
|
8a74758d56 | ||
|
|
26fdf8fb75 | ||
|
|
9328063547 | ||
|
|
fc66b8251e | ||
|
|
394576368a | ||
|
|
f80e34cffe | ||
|
|
59ad2c2ca0 |
No files matched your search
+51
-2
@@ -8,18 +8,67 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.4.0",
|
||||
"version": "18.8.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.4.0",
|
||||
"version": "18.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -35,19 +35,19 @@
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.4.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -51,6 +52,7 @@ 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();
|
||||
@@ -146,10 +148,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
await runFastlaneGym(ctx, {
|
||||
return await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -171,6 +173,18 @@ 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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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,11 +1,4 @@
|
||||
import {
|
||||
BuildJob,
|
||||
EasCliNpmTags,
|
||||
Env,
|
||||
Metadata,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildJob, 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';
|
||||
@@ -14,7 +7,7 @@ import Joi from 'joi';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { isAtLeastNpm7Async } from '../utils/packageManager';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
|
||||
|
||||
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
|
||||
job: Joi.object().unknown(),
|
||||
@@ -121,33 +114,6 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
return env;
|
||||
}
|
||||
|
||||
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
cmd: string;
|
||||
args: string[];
|
||||
extraEnv: Env;
|
||||
}> {
|
||||
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
oldJob,
|
||||
result,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,10 +82,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
|
||||
@@ -173,7 +173,9 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return path.join(this.workingdir, 'env');
|
||||
}
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
|
||||
env: this.env,
|
||||
});
|
||||
}
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
if (!this._appConfig) {
|
||||
|
||||
@@ -19,12 +19,5 @@ export { PackageManager } from './utils/packageManager';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export {
|
||||
maybeStartPreparingPrecompiledModules,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
shouldUsePrecompiledDependencies,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from './utils/precompiledModules';
|
||||
|
||||
export * from './generic';
|
||||
@@ -22,6 +22,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -45,6 +49,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -70,6 +78,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -97,6 +109,10 @@ 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")
|
||||
"
|
||||
`;
|
||||
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
|
||||
'Info.plist application target'
|
||||
);
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
const ctx = {
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
logger: { info: jest.fn() },
|
||||
job: {},
|
||||
};
|
||||
await configureXcodeProject(ctx as any, options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials and versions for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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,6 +58,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -94,6 +96,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -131,6 +135,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
entitlements: {
|
||||
@@ -192,6 +198,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -210,6 +218,7 @@ 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',
|
||||
@@ -226,6 +235,7 @@ 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',
|
||||
@@ -243,6 +253,7 @@ 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,127 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import {
|
||||
startPreparingPrecompiledDependencies,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
} from '../../utils/precompiledModules';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installPods.name, () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
let mkdtempCallCount = 0;
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => {
|
||||
mkdtempCallCount += 1;
|
||||
return mkdtempCallCount === 1
|
||||
? ('/tmp/precompiled-modules-archive' as any)
|
||||
: ('/tmp/precompiled-modules-staging' as any);
|
||||
});
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
let resolvePreparation: (() => void) | undefined;
|
||||
const preparationPromise = new Promise<void>(resolve => {
|
||||
resolvePreparation = resolve;
|
||||
});
|
||||
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, [
|
||||
'https://example.com/precompiled-modules-0.zip',
|
||||
'https://example.com/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const installPodsPromise = installPods(ctx, {});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
|
||||
resolvePreparation?.();
|
||||
await installPodsPromise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
await expect(installPods(ctx, {})).resolves.toEqual({
|
||||
spawnPromise: expect.any(Promise),
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
async function configureXcodeProject(
|
||||
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -137,6 +138,10 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
entitlements: object | null;
|
||||
extraEnv?: Env;
|
||||
}
|
||||
): Promise<void> {
|
||||
): Promise<{
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
}> {
|
||||
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
|
||||
await ensureGymfileExists(ctx, {
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
|
||||
cwd: workspacePath,
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
}
|
||||
|
||||
return { derivedDataPath, workspacePath };
|
||||
}
|
||||
|
||||
export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
@@ -122,6 +131,8 @@ 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`;
|
||||
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration: buildConfiguration ?? 'release',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
outputDirectory: './build',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
entitlements: entitlements ?? undefined,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
outputDirectory: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
entitlements?: object;
|
||||
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
simulatorDestination: string;
|
||||
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
}: ArchiveBuildOptions): Promise<void> {
|
||||
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
|
||||
@@ -59,6 +64,8 @@ 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,
|
||||
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
}: SimulatorBuildOptions): Promise<void> {
|
||||
@@ -86,6 +94,7 @@ 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,
|
||||
},
|
||||
|
||||
@@ -3,21 +3,11 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
|
||||
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,6 +9,8 @@ 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';
|
||||
@@ -17,9 +19,10 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
|
||||
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';
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
createRestoreBuildCacheFunction,
|
||||
} from './functions/restoreBuildCache';
|
||||
import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
@@ -48,10 +52,14 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
createRestoreBuildCacheFunction(),
|
||||
@@ -68,6 +76,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
configureIosVersionFunction(),
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
@@ -81,8 +90,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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,5 +1,12 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -30,6 +37,7 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -38,17 +46,30 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -56,11 +77,13 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,16 +96,19 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -91,6 +117,7 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -98,18 +125,26 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -117,29 +152,35 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -147,16 +188,18 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: './ios',
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -166,6 +209,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -173,6 +217,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -181,6 +226,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -188,10 +234,15 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -199,11 +250,20 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
},
|
||||
}),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -215,24 +275,29 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -242,6 +307,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -249,6 +315,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -257,6 +324,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -264,18 +332,26 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -286,24 +362,29 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -313,6 +394,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -320,6 +402,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -328,6 +411,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -335,21 +419,33 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -359,8 +455,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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,59 +0,0 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
|
||||
import { createInstallPodsBuildFunction } from '../installPods';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/precompiledModules', () => ({
|
||||
...jest.requireActual('../../../utils/precompiledModules'),
|
||||
waitForPrecompiledModulesPreparationAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(createInstallPodsBuildFunction, () => {
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest
|
||||
.mocked(waitForPrecompiledModulesPreparationAsync)
|
||||
.mockRejectedValue(new Error('precompiled dependencies failed'));
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
|
||||
|
||||
describe(createInternalEasMaestroTestFunction, () => {
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue(undefined as any);
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: null });
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
|
||||
try {
|
||||
return await fn(attemptCount);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
});
|
||||
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
|
||||
]);
|
||||
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
|
||||
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
|
||||
{ serialId: 'emulator-5554', state: 'device' } as any,
|
||||
]);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue({
|
||||
serialId: 'emulator-5556',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
|
||||
});
|
||||
|
||||
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
};
|
||||
const fn = createInternalEasMaestroTestFunction(ctx as any);
|
||||
return fn.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
flow_paths: JSON.stringify(['.maestro']),
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('sets junit_report_directory output when output_format is junit', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toMatch(/maestro-reports-/);
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory output when output_format is not junit', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory when function throws before reaching output code', async () => {
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch {
|
||||
// Expected - no booted device found
|
||||
}
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries Android clone startup with increasing timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-2',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
deviceName: 'eas-simulator-0-0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('generates per-attempt report filenames when retrying', async () => {
|
||||
// Mock sequence:
|
||||
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
|
||||
// 2. maestro test attempt 0 → fails (triggers retry)
|
||||
// 3. maestro test attempt 1 → succeeds (beforeEach default)
|
||||
mockedSpawnAsync
|
||||
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
|
||||
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
|
||||
// Attempt 1 falls through to beforeEach's mockResolvedValue default
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit', retries: 2 },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
|
||||
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
|
||||
expect(maestroCalls).toHaveLength(2);
|
||||
|
||||
// Extract --output paths from maestro calls
|
||||
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
|
||||
|
||||
expect(outputArgs[0]).toContain('attempt-0');
|
||||
expect(outputArgs[1]).toContain('attempt-1');
|
||||
// Filenames must be unique (not overwriting)
|
||||
expect(outputArgs[0]).not.toBe(outputArgs[1]);
|
||||
});
|
||||
|
||||
it('fails Android flow when clone startup exhausts all attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-3',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-3',
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { createReadAppConfigBuildFunction } from '../readAppConfig';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
|
||||
describe(createReadAppConfigBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('reads and logs app configuration', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({
|
||||
logger,
|
||||
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
|
||||
});
|
||||
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
|
||||
logger: stepLogger,
|
||||
sdkVersion: '54.0.0',
|
||||
})
|
||||
);
|
||||
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
|
||||
expect(stepLogger.info).toHaveBeenCalledWith(
|
||||
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
|
||||
);
|
||||
const firstOutput = buildStep.outputById.app_config.value;
|
||||
expect(firstOutput).toBeDefined();
|
||||
expect(JSON.parse(firstOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 'my-app-slug',
|
||||
version: '1.2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if reading app config fails', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('exports app fields as returned by app config', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const secondOutput = buildStep.outputById.app_config.value;
|
||||
expect(secondOutput).toBeDefined();
|
||||
expect(JSON.parse(secondOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAndLogPackageJson } from '../../../utils/project';
|
||||
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
|
||||
|
||||
jest.mock('../../../utils/project');
|
||||
|
||||
describe(createReadPackageJsonBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('logs package.json contents', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAndLogPackageJson).toHaveBeenCalledWith(
|
||||
stepLogger,
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
expect(buildStep.outputById.package_json.value).toBe(
|
||||
JSON.stringify({ name: 'app', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if reading package.json fails', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
|
||||
throw new Error('failed to read package.json');
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
expect(stepLogger.error).toHaveBeenCalledWith({
|
||||
err: expect.any(Error),
|
||||
});
|
||||
expect(buildStep.outputById.package_json.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
@@ -212,4 +211,64 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails early on Linux when emulator host support check fails', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
|
||||
|
||||
const step = createStep();
|
||||
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
|
||||
});
|
||||
|
||||
it('continues startup on Linux when emulator host support check succeeds', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips emulator host support check when opt out env var is enabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: undefined,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: '/android/sdk/root',
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'/android/sdk/root/emulator/emulator',
|
||||
['-accel-check'],
|
||||
{
|
||||
env: expect.any(Object),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
|
||||
import { configureCredentialsAsync } from '../utils/ios/configure';
|
||||
@@ -26,9 +31,14 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'target_names',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs }) => {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
@@ -50,6 +60,7 @@ 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,7 +1,8 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Ios, UserError } 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';
|
||||
@@ -17,10 +18,15 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'credentials',
|
||||
required: true,
|
||||
required: false,
|
||||
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,
|
||||
@@ -38,19 +44,6 @@ 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;
|
||||
|
||||
@@ -59,8 +52,9 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
const appVersion =
|
||||
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
|
||||
if (appVersion && !semver.valid(appVersion)) {
|
||||
throw new Error(
|
||||
`App verrsion provided by the "app_version" input is not a valid semver version: ${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}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +65,33 @@ 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}`);
|
||||
@@ -87,7 +108,7 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
appVersion,
|
||||
},
|
||||
{
|
||||
targetNames: Object.keys(credentials.targetProvisioningProfiles),
|
||||
targetNames,
|
||||
buildConfiguration: resolveBuildConfiguration(
|
||||
job,
|
||||
inputs.build_configuration.value as string | undefined
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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,6 +6,7 @@ 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';
|
||||
@@ -36,6 +37,10 @@ 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 %>")
|
||||
`;
|
||||
|
||||
@@ -55,6 +60,9 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
|
||||
export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
@@ -181,6 +189,7 @@ 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);
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
|
||||
|
||||
if (packagerRunDir !== stepCtx.workingDirectory) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
|
||||
|
||||
export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -10,14 +8,6 @@ export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
name: 'Install Pods',
|
||||
__metricsId: 'eas/install_pods',
|
||||
fn: async (stepsCtx, { env }) => {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
stepsCtx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
stepsCtx.logger.info('Installing pods');
|
||||
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag =
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
import { GenericArtifactType } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { Result, asyncResult, result } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { PlatformToProperNounMap } from '../../utils/strings';
|
||||
|
||||
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: '__maestro_test',
|
||||
__metricsId: 'eas/__maestro_test',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'platform',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
id: 'flow_paths',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'retries',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'shards',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
id: 'record_screen',
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'test_reports_artifact_id',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
|
||||
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
|
||||
const {
|
||||
platform,
|
||||
flow_paths,
|
||||
retries,
|
||||
include_tags,
|
||||
exclude_tags,
|
||||
shards,
|
||||
output_format,
|
||||
record_screen,
|
||||
} = z
|
||||
.object({
|
||||
platform: z.enum(['ios', 'android']),
|
||||
flow_paths: z.array(z.string()),
|
||||
retries: z.number().default(1),
|
||||
include_tags: z.string().optional(),
|
||||
exclude_tags: z.string().optional(),
|
||||
shards: z.number().default(1),
|
||||
output_format: z.string().optional(),
|
||||
record_screen: z.boolean().default(false),
|
||||
})
|
||||
.parse(
|
||||
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
|
||||
);
|
||||
|
||||
const flowPathsToExecute: string[] = [];
|
||||
for (const flowPath of flow_paths) {
|
||||
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
flowPath,
|
||||
logger: stepCtx.logger,
|
||||
includeTags: include_tags ? include_tags.split(',') : undefined,
|
||||
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
|
||||
});
|
||||
if (flowPaths.length === 0) {
|
||||
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
|
||||
continue;
|
||||
}
|
||||
stepCtx.logger.info(
|
||||
`Marking for execution:\n- ${flowPaths
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
stepCtx.logger.info('');
|
||||
flowPathsToExecute.push(...flowPaths);
|
||||
}
|
||||
|
||||
// TODO: Add support for shards. (Shouldn't be too difficult.)
|
||||
if (shards > 1) {
|
||||
stepCtx.logger.warn(
|
||||
'Sharding support has been temporarily disabled. Running tests on a single shard.'
|
||||
);
|
||||
}
|
||||
|
||||
// eas/__maestro_test does not start devices, it expects a single device to be already running
|
||||
// and configured with the app. Here we find the booted device and stop it.
|
||||
|
||||
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (bootedDevices.length === 0) {
|
||||
throw new Error('No booted iOS Simulator found.');
|
||||
} else if (bootedDevices.length > 1) {
|
||||
throw new Error('Multiple booted iOS Simulators found.');
|
||||
}
|
||||
|
||||
const device = bootedDevices[0];
|
||||
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Simulator for tests...`);
|
||||
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
|
||||
logger: stepCtx.logger,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
sourceDeviceIdentifier = device.name;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
if (connectedDevices.length === 0) {
|
||||
throw new Error('No booted Android Emulator found.');
|
||||
} else if (connectedDevices.length > 1) {
|
||||
throw new Error('Multiple booted Android Emulators found.');
|
||||
}
|
||||
|
||||
const { serialId } = connectedDevices[0];
|
||||
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
mode: PipeMode.COMBINED,
|
||||
env,
|
||||
});
|
||||
const avdName = adbEmuAvdNameResult.stdout
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')[0] as AndroidVirtualDeviceName;
|
||||
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Emulator for tests...`);
|
||||
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
|
||||
await setTimeout(1000);
|
||||
|
||||
sourceDeviceIdentifier = avdName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// During tests we generate reports and device logs. We store them in temporary directories
|
||||
// and upload them once all tests are done. When a test is retried, new reports overwrite
|
||||
// the old ones. The files are named "flow-${index}" for easier identification.
|
||||
const maestroReportsDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-reports-')
|
||||
);
|
||||
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
|
||||
|
||||
const failedFlows: string[] = [];
|
||||
|
||||
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
|
||||
// Generate unique report path per attempt (not overwritten on retry)
|
||||
const outputPath = path.join(
|
||||
maestroReportsDir,
|
||||
[
|
||||
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
|
||||
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
|
||||
| IosSimulatorName
|
||||
| AndroidVirtualDeviceName;
|
||||
|
||||
// If the test passes, but the recording fails, we don't want to make the test fail,
|
||||
// so we return two separate results.
|
||||
const {
|
||||
fnResult: { fnResult, recordingResult },
|
||||
logsResult,
|
||||
} = await withCleanDeviceAsync({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async ({ deviceIdentifier }) => {
|
||||
return await maybeWithScreenRecordingAsync({
|
||||
shouldRecord: record_screen,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async () => {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
const [command, ...args] = getMaestroTestCommand({
|
||||
flow_path: flowPath,
|
||||
output_format,
|
||||
output_path: outputPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnAsync(command, args, {
|
||||
logger: stepCtx.logger,
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} finally {
|
||||
stepCtx.logger.info('');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Move device logs to the device logs directory.
|
||||
if (logsResult?.ok) {
|
||||
try {
|
||||
const extension = path.extname(logsResult.value.outputPath);
|
||||
const destinationPath = path.join(
|
||||
deviceLogsDir,
|
||||
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
|
||||
);
|
||||
|
||||
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
|
||||
}
|
||||
} else if (logsResult?.reason) {
|
||||
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
|
||||
}
|
||||
|
||||
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
|
||||
if (isLastAttempt && recordingResult.value) {
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
|
||||
name: `Screen Recording (${flowIndex}-${path.basename(
|
||||
flowPath,
|
||||
path.extname(flowPath)
|
||||
)})`,
|
||||
paths: [recordingResult.value],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
|
||||
}
|
||||
}
|
||||
|
||||
if (fnResult.ok) {
|
||||
stepCtx.logger.info(`Test passed.`);
|
||||
// Break out of the retry loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if (attemptCount < retries - 1) {
|
||||
stepCtx.logger.info(`Retrying test...`);
|
||||
stepCtx.logger.info('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
|
||||
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
|
||||
failedFlows.push(flowPath);
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// When all tests are done, we upload the reports and device logs.
|
||||
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
|
||||
if (generatedMaestroReports.length === 0) {
|
||||
stepCtx.logger.warn('No reports were generated.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading reports...`);
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
|
||||
paths: [maestroReportsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
if (artifactId) {
|
||||
outputs.test_reports_artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload reports.');
|
||||
}
|
||||
}
|
||||
|
||||
if (output_format === 'junit') {
|
||||
outputs.junit_report_directory.set(maestroReportsDir);
|
||||
}
|
||||
|
||||
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
|
||||
if (generatedDeviceLogs.length === 0) {
|
||||
stepCtx.logger.warn('No device logs were successfully collected.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading device logs...`);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `Maestro Test Device Logs`,
|
||||
paths: [deviceLogsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If any tests failed, we throw an error to mark the step as failed.
|
||||
if (failedFlows.length > 0) {
|
||||
throw new Error(
|
||||
`Some Maestro tests failed:\n- ${failedFlows
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
} else {
|
||||
stepCtx.logger.info('All Maestro tests passed.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getMaestroTestCommand(params: {
|
||||
flow_path: string;
|
||||
output_format: string | undefined;
|
||||
/** Unused if `output_format` is undefined */
|
||||
output_path: string;
|
||||
}): [command: string, ...args: string[]] {
|
||||
let outputFormatFlags: string[] = [];
|
||||
if (params.output_format) {
|
||||
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
|
||||
}
|
||||
|
||||
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
|
||||
command: string,
|
||||
...args: string[],
|
||||
];
|
||||
}
|
||||
|
||||
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
|
||||
junit: 'xml',
|
||||
html: 'html',
|
||||
};
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
async function withCleanDeviceAsync<TResult>({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
platform: 'ios' | 'android';
|
||||
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
fn: ({
|
||||
deviceIdentifier,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
}) => Promise<TResult>;
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.cloneAsync({
|
||||
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
|
||||
destinationDeviceName: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid,
|
||||
env,
|
||||
});
|
||||
localDeviceIdentifier = localDeviceName as IosSimulatorName;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(
|
||||
{ err: cleanupErr },
|
||||
`Failed to clean up Android Emulator ${localDeviceName}.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!localDeviceIdentifier) {
|
||||
throw new Error('Device did not return an identifier after startup.');
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
|
||||
// Stop the device
|
||||
|
||||
let logsResult: Result<{ outputPath: string }> | null = null;
|
||||
|
||||
try {
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
IosSimulatorUtils.collectLogsAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.deleteAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error cleaning up device: ${err}`);
|
||||
}
|
||||
|
||||
return { fnResult: fnResult.enforceValue(), logsResult };
|
||||
}
|
||||
|
||||
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
|
||||
async function maybeWithScreenRecordingAsync<TResult>({
|
||||
shouldRecord,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
// As weird as it is, it's more convenient to have this function like `maybeWith...`
|
||||
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
|
||||
shouldRecord: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
fn: () => Promise<TResult>;
|
||||
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
|
||||
if (!shouldRecord) {
|
||||
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
|
||||
}
|
||||
|
||||
let recordingResult: Result<{
|
||||
recordingSpawn: SpawnPromise<SpawnResult>;
|
||||
outputPath?: string;
|
||||
}>;
|
||||
|
||||
// Start screen recording
|
||||
|
||||
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
recordingResult = await asyncResult(
|
||||
IosSimulatorUtils.startScreenRecordingAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
recordingResult = await asyncResult(
|
||||
AndroidEmulatorUtils.startScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
logger.warn('Failed to start screen recording.', recordingResult.reason);
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn());
|
||||
|
||||
// If recording failed there's nothing to stop, so we return the results
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
return { fnResult, recordingResult: result(recordingResult.reason) };
|
||||
}
|
||||
|
||||
// If recording started, finish it
|
||||
|
||||
try {
|
||||
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
await IosSimulatorUtils.stopScreenRecordingAsync({
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
});
|
||||
return {
|
||||
fnResult,
|
||||
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
|
||||
recordingResult: result(recordingResult.value.outputPath!),
|
||||
};
|
||||
}
|
||||
case 'android': {
|
||||
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
env,
|
||||
});
|
||||
return { fnResult, recordingResult: result(outputPath) };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stop screen recording.', err);
|
||||
|
||||
return { fnResult, recordingResult: result(err as Error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
|
||||
|
||||
const job = stepCtx.global.staticContext.job;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
|
||||
export function createReadAppConfigBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_app_config',
|
||||
name: 'Read app config',
|
||||
__metricsId: 'eas/read_app_config',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'app_config',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, outputs }) => {
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
stepCtx.logger.info('Using app configuration:');
|
||||
stepCtx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
outputs.app_config.set(JSON.stringify(appConfig));
|
||||
} catch {
|
||||
// readAppConfig logs underlying failures.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildFunction, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { readAndLogPackageJson } from '../../utils/project';
|
||||
|
||||
export function createReadPackageJsonBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_package_json',
|
||||
name: 'Read package.json',
|
||||
__metricsId: 'eas/read_package_json',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'package_json',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
try {
|
||||
const packageJson = readAndLogPackageJson(stepCtx.logger, stepCtx.workingDirectory);
|
||||
outputs.package_json.set(JSON.stringify(packageJson));
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
if (env.EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import {
|
||||
@@ -44,6 +50,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
@@ -253,3 +267,32 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const isAndroidEmulatorHostSupported = await isAndroidEmulatorHostSupportedAsync(env);
|
||||
if (!isAndroidEmulatorHostSupported) {
|
||||
throw new UserError(
|
||||
'ANDROID_EMULATOR_ACCEL_CHECK_UNAVAILABLE',
|
||||
'Could not start the Android emulator on this runner.\n\n' +
|
||||
'Android emulator requires nested virtualization on Linux. This job does not have the required virtualization support.\n\n' +
|
||||
'Update your workflow YAML to use a nested-virtualization Linux runner, for example:\n' +
|
||||
' runs_on: linux-medium-nested-virtualization\n' +
|
||||
' runs_on: linux-large-nested-virtualization\n\n' +
|
||||
'See https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idruns_on for more information.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAndroidEmulatorHostSupportedAsync(env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
const androidSdkPath = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? '';
|
||||
try {
|
||||
await spawn(`${androidSdkPath}/emulator/emulator`, ['-accel-check'], { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
|
||||
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
|
||||
export async function configureCredentialsAsync(
|
||||
logger: bunyan,
|
||||
@@ -29,6 +30,9 @@ export async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -133,6 +134,10 @@ export default class ProvisioningProfile {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
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,5 +20,9 @@ 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,4 +13,7 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
-400
@@ -1,400 +0,0 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import bunyan from 'bunyan';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
|
||||
|
||||
// We use node:fs
|
||||
jest.unmock('node:fs');
|
||||
// and fg uses fs.
|
||||
jest.unmock('fs');
|
||||
jest.setTimeout(15000);
|
||||
|
||||
/**
|
||||
*
|
||||
* You need to have a Simulator running to run these tests.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('findMaestroPathsFlowsToExecuteAsync', () => {
|
||||
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
|
||||
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
|
||||
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
|
||||
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
|
||||
|
||||
let logger: bunyan;
|
||||
|
||||
beforeAll(() => {
|
||||
logger = createMockLogger();
|
||||
});
|
||||
|
||||
// Helper function to run maestro test and determine which flows it executed
|
||||
// by parsing the output
|
||||
async function getMaestroFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const args = ['test', flowPath, '--no-ansi'];
|
||||
|
||||
if (includeTags.length > 0) {
|
||||
args.push('--include-tags', includeTags.join(','));
|
||||
}
|
||||
|
||||
if (excludeTags.length > 0) {
|
||||
args.push('--exclude-tags', excludeTags.join(','));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAsync('maestro', args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseMaestroExecutedFlows(result.stdout + result.stderr);
|
||||
} catch (error: any) {
|
||||
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
|
||||
return parseMaestroExecutedFlows(output);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMaestroExecutedFlows(output: string): string[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
|
||||
.map(line => {
|
||||
if (line.includes('[Passed]')) {
|
||||
return line.split('[Passed]')[1].trim().split(' ')[0];
|
||||
}
|
||||
return line.split('> Flow')[1].trim();
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Helper function to run our implementation
|
||||
async function getOurFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const result = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: process.cwd(),
|
||||
flowPath: path.relative(process.cwd(), flowPath),
|
||||
includeTags,
|
||||
excludeTags,
|
||||
logger,
|
||||
});
|
||||
|
||||
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
|
||||
}
|
||||
|
||||
describe('Directory-based flow discovery', () => {
|
||||
it('should discover all flows when no tags are specified', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir);
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by both include and exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should not include nested flows in subdirectories', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should exclude config files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude non-YAML files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).not.toContain('non-flow-file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single file scenarios', () => {
|
||||
it('should handle single file input', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
const maestroFlows = await getMaestroFlowList(singleFile);
|
||||
const ourFlows = await getOurFlowList(singleFile);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (include)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be included
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (exclude)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be excluded
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle non-existent paths gracefully', async () => {
|
||||
const nonExistentPath = '/path/that/does/not/exist';
|
||||
|
||||
// Our implementation should return empty array for non-existent paths
|
||||
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const ourFlows = await getOurFlowList(emptyDir);
|
||||
expect(ourFlows).toEqual([]);
|
||||
} finally {
|
||||
await fs.rmdir(emptyDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle flows that cannot be parsed', async () => {
|
||||
// The invalid-flow.yaml has extra fields that might cause parsing issues
|
||||
// but should still be discovered if tag filtering passes
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
|
||||
|
||||
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
|
||||
// The important thing is that it doesn't crash
|
||||
expect(Array.isArray(ourFlows)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance comparison', () => {
|
||||
it('should complete discovery in reasonable time', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await getOurFlowList(fixturesDir);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailed flow content verification', () => {
|
||||
it('should correctly identify flows with specific tag combinations', async () => {
|
||||
// Test complex tag filtering scenarios
|
||||
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
|
||||
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
|
||||
|
||||
// Verify specific files are included/excluded correctly
|
||||
expect(authFlows).toContain('tagged-flow-auth');
|
||||
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
|
||||
expect(smokeFlows).toContain('tagged-flow-smoke');
|
||||
expect(criticalFlows).toContain('tagged-flow-smoke');
|
||||
});
|
||||
|
||||
it('should handle flows without tags correctly', async () => {
|
||||
// When no filters are applied, flows without tags should be included
|
||||
const allFlows = await getOurFlowList(fixturesDir);
|
||||
expect(allFlows).toContain('no-tags-flow');
|
||||
expect(allFlows).toContain('basic-flow');
|
||||
|
||||
// When include tags are specified, flows without tags should be excluded
|
||||
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
expect(taggedFlows).not.toContain('no-tags-flow');
|
||||
expect(taggedFlows).not.toContain('basic-flow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml flow patterns', () => {
|
||||
it('should discover flows using config.yaml patterns', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configFlowsDir);
|
||||
const ourFlows = await getOurFlowList(configFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Verify specific files are included based on config patterns
|
||||
expect(ourFlows).toContain('login'); // features/auth/login.yaml
|
||||
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
|
||||
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
|
||||
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
|
||||
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
|
||||
|
||||
// Verify top-level file is excluded (not matching patterns)
|
||||
expect(ourFlows).not.toContain('ignored-top-level');
|
||||
});
|
||||
|
||||
it('should handle tag filtering with config.yaml patterns', async () => {
|
||||
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
|
||||
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
|
||||
|
||||
expect(authFlows).toEqual(maestroAuthFlows);
|
||||
expect(authFlows).toContain('login');
|
||||
expect(authFlows).toContain('logout');
|
||||
expect(authFlows).not.toContain('navigation');
|
||||
expect(authFlows).not.toContain('api.spec');
|
||||
});
|
||||
|
||||
it('should handle exclude tags with config.yaml patterns', async () => {
|
||||
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
|
||||
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
|
||||
|
||||
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
|
||||
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
|
||||
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
|
||||
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
|
||||
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
|
||||
});
|
||||
|
||||
it('should handle multiple include tags with config.yaml patterns', async () => {
|
||||
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
|
||||
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
|
||||
'auth',
|
||||
'integration',
|
||||
]);
|
||||
|
||||
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
|
||||
expect(multiTagFlows).toContain('login'); // auth tag
|
||||
expect(multiTagFlows).toContain('logout'); // auth tag
|
||||
expect(multiTagFlows).toContain('api.spec'); // integration tag
|
||||
expect(multiTagFlows).toContain('ui.spec'); // integration tag
|
||||
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml includeTags/excludeTags merging', () => {
|
||||
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
|
||||
|
||||
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir);
|
||||
const ourFlows = await getOurFlowList(configTagsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-cli-include');
|
||||
});
|
||||
|
||||
it('should merge CLI includeTags with config includeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).toContain('flow-cli-include');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
});
|
||||
|
||||
it('should merge CLI excludeTags with config excludeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Exclude both cfgExclude and cliExclude
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
});
|
||||
|
||||
it('should apply both merged include and exclude tags correctly', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility (no config.yaml)', () => {
|
||||
it('should use default "*" pattern when no config.yaml exists', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
|
||||
const ourFlows = await getOurFlowList(noConfigFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Should include top-level files
|
||||
expect(ourFlows).toContain('top-level-flow');
|
||||
|
||||
// Should exclude subdirectory files (default "*" behavior)
|
||||
expect(ourFlows).not.toContain('nested-flow');
|
||||
});
|
||||
|
||||
it('should handle tag filtering without config.yaml', async () => {
|
||||
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
|
||||
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
|
||||
|
||||
expect(basicFlows).toEqual(maestroBasicFlows);
|
||||
expect(basicFlows).toContain('top-level-flow');
|
||||
|
||||
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
|
||||
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
|
||||
|
||||
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
|
||||
expect(noIncludeTagsFlows).toContain('top-level-flow');
|
||||
expect(noIncludeTagsFlows).not.toContain('nested-flow');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
flows:
|
||||
- 'features/**/*.yaml'
|
||||
- 'specs/*.spec.yaml'
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "login"
|
||||
tags: ["auth", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "logout"
|
||||
tags: ["auth"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "navigation"
|
||||
tags: ["core", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ignored-top-level"
|
||||
tags: ["ignored"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "api.spec"
|
||||
tags: ["api", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ui.spec"
|
||||
tags: ["ui", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "basic-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "invalid-flow"
|
||||
tags:
|
||||
- invalid
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "no-tags-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-1
@@ -1 +0,0 @@
|
||||
This is not a YAML file and should be ignored by flow discovery.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags:
|
||||
- nested
|
||||
- auth
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-auth"
|
||||
tags:
|
||||
- auth
|
||||
- login
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-regression"
|
||||
tags:
|
||||
- regression
|
||||
- slow
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-smoke"
|
||||
tags:
|
||||
- smoke
|
||||
- critical
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags: ["nested"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "top-level-flow"
|
||||
tags: ["basic"]
|
||||
---
|
||||
- assertTrue: true
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "standalone-flow"
|
||||
tags:
|
||||
- standalone
|
||||
- smoke
|
||||
---
|
||||
- assertTrue: true
|
||||
@@ -1,120 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('precompiledModules integration', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('merges the extracted debug and release xcframework trees', async () => {
|
||||
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = homeDirectory;
|
||||
|
||||
try {
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
|
||||
const debugArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
|
||||
);
|
||||
const releaseArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
|
||||
);
|
||||
|
||||
nock('https://fixtures.expo.test')
|
||||
.get('/xcframeworks-Debug.zip')
|
||||
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
|
||||
.get('/xcframeworks-Release.zip')
|
||||
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('os', () => ({
|
||||
...jest.requireActual('os'),
|
||||
homedir: () => homeDirectory,
|
||||
}));
|
||||
const {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} = await import('../precompiledModules');
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
{
|
||||
logger: createMockLogger(),
|
||||
env: {},
|
||||
} as any,
|
||||
[
|
||||
'https://fixtures.expo.test/xcframeworks-Debug.zip',
|
||||
'https://fixtures.expo.test/xcframeworks-Release.zip',
|
||||
]
|
||||
);
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(PRECOMPILED_MODULES_PATH).toBe(
|
||||
path.join(homeDirectory, '.expo', 'precompiled-modules')
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-camera fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-application fixture\n');
|
||||
} finally {
|
||||
jest.dontMock('os');
|
||||
jest.resetModules();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.remove(homeDirectory);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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,3 +1,4 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
@@ -5,6 +6,7 @@ import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolvePackageManager,
|
||||
@@ -23,23 +25,23 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
|
||||
it('returns yarn when no lockfiles exist', async () => {
|
||||
expect(resolvePackageManager(rootDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).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)).toBe('npm');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
|
||||
});
|
||||
|
||||
it('returns yarn when only yarn.lock exists', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
|
||||
expect(resolvePackageManager(rootDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).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)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('returns npm within a monorepo', async () => {
|
||||
@@ -55,7 +57,7 @@ describe(resolvePackageManager, () => {
|
||||
name: '@monorepo/expo-app',
|
||||
});
|
||||
|
||||
expect(resolvePackageManager(nestedDir)).toBe('npm');
|
||||
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
|
||||
});
|
||||
|
||||
it('returns yarn with an invalid monorepo', async () => {
|
||||
@@ -67,7 +69,51 @@ describe(resolvePackageManager, () => {
|
||||
await fs.mkdirp(nestedDir);
|
||||
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
|
||||
|
||||
expect(resolvePackageManager(nestedDir)).toBe('yarn');
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from '../precompiledModules';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('precompiledModules', () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
const getMkdtempPath = jest.fn();
|
||||
|
||||
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
|
||||
({
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
}) as unknown as BuildContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
getMkdtempPath.mockReset();
|
||||
getMkdtempPath
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('downloads through CocoaPods proxy when enabled', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
|
||||
jest
|
||||
.mocked(downloadFile)
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts all configured archives into the well-known destination path', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
|
||||
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
|
||||
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('leaves the destination empty when preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
extract.mockRejectedValueOnce(new Error('extract failed'));
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]);
|
||||
|
||||
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
|
||||
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
|
||||
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://example.com/xcframeworks-Debug.zip',
|
||||
]);
|
||||
|
||||
const waitPromise = waitForPrecompiledModulesPreparationAsync();
|
||||
const waitExpectation = expect(waitPromise).rejects.toThrow(
|
||||
'Timed out waiting for precompiled dependencies after 30 seconds'
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await waitExpectation;
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,29 @@ import { ExpoConfig } from '@expo/config';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { PackageManager } from '../packageManager';
|
||||
import { readEasJsonContents, runExpoCliCommand } from '../project';
|
||||
import { PackageManager, findPackagerRootDir } from '../packageManager';
|
||||
import {
|
||||
isUsingModernYarnVersion,
|
||||
readAndLogPackageJson,
|
||||
readEasJsonContents,
|
||||
runExpoCliCommand,
|
||||
} from '../project';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
findPackagerRootDir: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe(runExpoCliCommand, () => {
|
||||
describe('Expo SDK >= 46', () => {
|
||||
@@ -75,6 +86,61 @@ describe(runExpoCliCommand, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isUsingModernYarnVersion, () => {
|
||||
const projectDir = '/project';
|
||||
const mockedFindPackagerRootDir = findPackagerRootDir as jest.MockedFunction<
|
||||
typeof findPackagerRootDir
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFindPackagerRootDir.mockReturnValue(projectDir);
|
||||
jest.spyOn(fs, 'createReadStream').mockImplementation(((
|
||||
filePath: string,
|
||||
options?: { start?: number; end?: number }
|
||||
) => {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const start = options?.start ?? 0;
|
||||
const end = options?.end != null ? options.end + 1 : content.length;
|
||||
return Readable.from(Buffer.from(content.slice(start, end)));
|
||||
}) as any);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in project directory', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in workspace root directory', async () => {
|
||||
const workspaceRoot = '/workspace-root';
|
||||
mockedFindPackagerRootDir.mockReturnValue(workspaceRoot);
|
||||
vol.fromJSON({ [path.join(workspaceRoot, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no .yarnrc.yml and no yarn.lock exist', async () => {
|
||||
vol.fromJSON({ [projectDir]: null });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when yarn.lock contains classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when yarn.lock does not contain classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '__metadata:\n version: 8\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists even with classic yarn.lock', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(projectDir, '.yarnrc.yml')]: '',
|
||||
[path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n',
|
||||
});
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readEasJsonContents, () => {
|
||||
it('returns the raw eas.json contents even when it is not valid JSON', async () => {
|
||||
const projectDir = '/project';
|
||||
@@ -87,3 +153,23 @@ describe(readEasJsonContents, () => {
|
||||
expect(readEasJsonContents(projectDir)).toBe(contents);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readAndLogPackageJson, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('reads package.json, logs it, and returns parsed contents', async () => {
|
||||
const projectDir = '/project';
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = { name: 'app', version: '1.0.0' };
|
||||
const logger = { info: jest.fn() };
|
||||
|
||||
await fs.mkdirp(projectDir);
|
||||
await fs.writeJson(packageJsonPath, packageJson);
|
||||
|
||||
expect(readAndLogPackageJson(logger, projectDir)).toEqual(packageJson);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(1, 'Using package.json:');
|
||||
expect(logger.info).toHaveBeenNthCalledWith(2, JSON.stringify(packageJson, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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,230 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fg from 'fast-glob';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as yaml from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const FlowConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const WorkspaceConfigSchema = z.object({
|
||||
flows: z.array(z.string()).optional(),
|
||||
executionOrder: z.record(z.string(), z.unknown()).optional(),
|
||||
includeTags: z.array(z.string()).optional(),
|
||||
excludeTags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
||||
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
|
||||
|
||||
export async function findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory,
|
||||
flowPath,
|
||||
includeTags: _includeTags,
|
||||
excludeTags: _excludeTags,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
flowPath: string;
|
||||
includeTags: string[] | undefined;
|
||||
excludeTags: string[] | undefined;
|
||||
logger: bunyan;
|
||||
}): Promise<string[]> {
|
||||
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
|
||||
// If it's a file, just return it (no validation needed)
|
||||
const stat = await fs.stat(absoluteFlowPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
return [absoluteFlowPath];
|
||||
}
|
||||
|
||||
// It's a directory - discover flow files
|
||||
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
|
||||
// Check for workspace config
|
||||
logger.info(`Searching for workspace config...`);
|
||||
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
|
||||
|
||||
if (workspaceConfig?.executionOrder) {
|
||||
logger.warn(`Execution order is not supported yet. Ignoring.`);
|
||||
}
|
||||
|
||||
logger.info(`Searching for flow files...`);
|
||||
const { flows } = await findAndParseFlowFilesAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (flows.length === 0) {
|
||||
logger.info(
|
||||
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
|
||||
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
|
||||
|
||||
if (includeTags.length === 0 && excludeTags.length === 0) {
|
||||
logger.info(`No tags provided, returning all flows.`);
|
||||
return flows.map(({ path }) => path);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
|
||||
JSON.stringify(excludeTags) ?? 'none'
|
||||
}.`
|
||||
);
|
||||
return flows
|
||||
.filter(({ config, path: flowPath }) => {
|
||||
const shouldInclude = matchesTags({
|
||||
flowTags: config?.tags ?? [],
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
shouldInclude
|
||||
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
|
||||
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
|
||||
);
|
||||
|
||||
return shouldInclude;
|
||||
})
|
||||
.map(({ path }) => path);
|
||||
}
|
||||
|
||||
async function findAndParseWorkspaceConfigAsync({
|
||||
dirPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
}: {
|
||||
dirPath: string;
|
||||
workingDirectory: string;
|
||||
logger: bunyan;
|
||||
}): Promise<WorkspaceConfig | null> {
|
||||
const configPaths = await fg(['config.yaml', 'config.yml'], {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const configDoc = yaml.parse(content);
|
||||
if (!configDoc) {
|
||||
logger.warn(
|
||||
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
|
||||
return WorkspaceConfigSchema.parse(configDoc);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findAndParseFlowFilesAsync({
|
||||
workingDirectory,
|
||||
dirPath,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
dirPath: string;
|
||||
workspaceConfig: WorkspaceConfig | null;
|
||||
logger: bunyan;
|
||||
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
|
||||
const flows: { config: FlowConfig; path: string }[] = [];
|
||||
|
||||
// Determine flow patterns from config or use default
|
||||
const flowPatterns = workspaceConfig?.flows ?? ['*'];
|
||||
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
|
||||
|
||||
// Use fast-glob to find matching files
|
||||
const matchedFiles = await fg(flowPatterns, {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
|
||||
});
|
||||
|
||||
logger.info(`Found ${matchedFiles.length} potential flow files`);
|
||||
|
||||
// Parse each matched file
|
||||
for (const filePath of matchedFiles) {
|
||||
// Skip non-YAML files
|
||||
const ext = path.extname(filePath);
|
||||
if (ext !== '.yaml' && ext !== '.yml') {
|
||||
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await asyncResult(parseFlowFile(filePath));
|
||||
if (result.ok) {
|
||||
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
|
||||
flows.push({ config: result.value, path: filePath });
|
||||
} else {
|
||||
logger.info(
|
||||
{ err: result.reason },
|
||||
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { flows };
|
||||
}
|
||||
|
||||
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const documents = yaml.parseAllDocuments(content);
|
||||
const configDoc = documents[0];
|
||||
if (!configDoc) {
|
||||
throw new Error(`No config section found in ${filePath}`);
|
||||
}
|
||||
return FlowConfigSchema.parse(configDoc.toJS());
|
||||
}
|
||||
|
||||
function matchesTags({
|
||||
flowTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
flowTags: string[];
|
||||
includeTags: string[];
|
||||
excludeTags: string[];
|
||||
}): boolean {
|
||||
// Include logic: if includeTags is empty OR flow has any of the include tags
|
||||
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
|
||||
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
return includeMatch && excludeMatch;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -11,7 +13,10 @@ export enum PackageManager {
|
||||
BUN = 'bun',
|
||||
}
|
||||
|
||||
export function resolvePackageManager(directory: string): PackageManager {
|
||||
export function resolvePackageManager(
|
||||
directory: string,
|
||||
{ env }: { env: BuildStepEnv }
|
||||
): PackageManager {
|
||||
try {
|
||||
const manager = PackageManagerUtils.resolvePackageManager(directory);
|
||||
if (manager === 'npm') {
|
||||
@@ -20,12 +25,24 @@ export function resolvePackageManager(directory: string): PackageManager {
|
||||
return PackageManager.PNPM;
|
||||
} else if (manager === 'bun') {
|
||||
return PackageManager.BUN;
|
||||
} else {
|
||||
} else if (manager === 'yarn') {
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
} catch {
|
||||
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}).`
|
||||
);
|
||||
}
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
let precompiledModulesPreparationPromise: Promise<void> | null = null;
|
||||
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
|
||||
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
|
||||
|
||||
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
|
||||
return env.EAS_USE_PRECOMPILED_MODULES === '1';
|
||||
}
|
||||
|
||||
export function maybeStartPreparingPrecompiledModules(
|
||||
ctx: BuildContext,
|
||||
config: { precompiledModulesUrls: string[] }
|
||||
): void {
|
||||
if (!shouldUsePrecompiledDependencies(ctx.env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
|
||||
}
|
||||
|
||||
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
|
||||
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
|
||||
logger: ctx.logger,
|
||||
urls,
|
||||
destinationDirectory: PRECOMPILED_MODULES_PATH,
|
||||
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
|
||||
if (!precompiledModulesPreparationPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
precompiledModulesPreparationPromise,
|
||||
new Promise<void>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
|
||||
)
|
||||
);
|
||||
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
|
||||
timeoutHandle.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePrecompiledDependenciesAsync({
|
||||
logger,
|
||||
urls,
|
||||
destinationDirectory,
|
||||
cocoapodsProxyUrl,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
urls: string[];
|
||||
destinationDirectory: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
}): Promise<void> {
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
|
||||
const destinationParentDirectory = path.dirname(destinationDirectory);
|
||||
await fs.mkdirp(destinationParentDirectory);
|
||||
const stagingDirectory = await fs.mkdtemp(
|
||||
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
|
||||
);
|
||||
|
||||
logger.info('');
|
||||
logger.info(
|
||||
{
|
||||
destinationDirectory,
|
||||
urls,
|
||||
},
|
||||
'Starting precompiled dependencies download'
|
||||
);
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
|
||||
try {
|
||||
const archives = urls.map((url, index) => ({
|
||||
archiveName: path.basename(new URL(url).pathname),
|
||||
url,
|
||||
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
archives.map(async ({ url, archivePath }) => {
|
||||
await downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
for (const { archiveName, url, archivePath } of archives) {
|
||||
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
|
||||
await extractZipAsync(archivePath, stagingDirectory);
|
||||
logger.info(
|
||||
{ destinationDirectory: stagingDirectory },
|
||||
`Extracted ${archiveName} into staging precompiled dependencies directory`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.move(stagingDirectory, destinationDirectory);
|
||||
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
|
||||
} catch (error) {
|
||||
await fs.remove(stagingDirectory);
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.remove(archiveDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
}: {
|
||||
url: string;
|
||||
archivePath: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const proxiedUrl = cocoapodsProxyUrl
|
||||
? (() => {
|
||||
const parsedUrl = new URL(url);
|
||||
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
})()
|
||||
: null;
|
||||
if (proxiedUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, proxiedUrl },
|
||||
'Failed to download through CocoaPods proxy, retrying directly'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile(url, archivePath, { retry: 3 });
|
||||
}
|
||||
|
||||
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
|
||||
const zip = new StreamZip.async({ file: archivePath });
|
||||
try {
|
||||
await zip.extract(null, destinationDirectory);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,43 @@ import path from 'path';
|
||||
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
|
||||
async function readFirstChars(filePath: string, chars: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = fs.createReadStream(filePath, {
|
||||
start: 0,
|
||||
end: chars - 1,
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('data', chunk => {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir,
|
||||
* or if the `yarn.lock` file is classic one, not a modern one
|
||||
*/
|
||||
export async function isUsingModernYarnVersion(projectDir: string): Promise<boolean> {
|
||||
const rootDir = findPackagerRootDir(projectDir);
|
||||
const yarnrcPath = path.join(projectDir, '.yarnrc.yml');
|
||||
const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml');
|
||||
return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath));
|
||||
if ((await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const yarnlockPath = path.join(rootDir, 'yarn.lock');
|
||||
if (!(await fs.pathExists(yarnlockPath))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The yarn.lock file is for Yarn Classic, not Modern, if it contains "# yarn lockfile v1"
|
||||
const startOfLockfile = await readFirstChars(yarnlockPath, 100);
|
||||
return !/yarn lockfile v1/i.test(startOfLockfile);
|
||||
}
|
||||
|
||||
export function runExpoCliCommand({
|
||||
@@ -48,6 +78,16 @@ export function readPackageJson(projectDir: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
export function readAndLogPackageJson(
|
||||
logger: { info: (message: string) => void },
|
||||
projectDir: string
|
||||
): any {
|
||||
logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(projectDir);
|
||||
logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
export function readEasJsonContents(projectDir: string): string {
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
if (!fs.pathExistsSync(easJsonPath)) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["jest", "node"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gql.tada/ts-plugin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.4.0",
|
||||
"version": "18.8.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.4.0"
|
||||
"@expo/steps": "18.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.4.0",
|
||||
"version": "18.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -24,6 +24,428 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
cf_bundle_version: '42',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'complete',
|
||||
cf_bundle_version: '42',
|
||||
build: {
|
||||
id: 'build-abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context without build_upload', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with app_version', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'ready_for_review',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect app_version state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
app_version: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with external_beta', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'beta_approved',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect external_beta state', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
external_beta: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context with beta_feedback', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'screenshot',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects app_store_connect beta_feedback without url', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'crash',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect beta_feedback type', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
beta_feedback: {
|
||||
id: 'AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
type: 'invalid-type',
|
||||
url: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect build_upload context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: 'not-a-uuid',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app and account context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -403,6 +403,7 @@ describe('Ios.JobSchema', () => {
|
||||
);
|
||||
expect(value).not.toMatchObject(managedJob);
|
||||
});
|
||||
|
||||
test('validates channel', () => {
|
||||
const managedJob = {
|
||||
secrets: {
|
||||
|
||||
@@ -226,6 +226,72 @@ const GitHubContextZ = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const AppStoreConnectContextZ = z.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
app_version: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'accepted',
|
||||
'developer_rejected',
|
||||
'in_review',
|
||||
'invalid_binary',
|
||||
'metadata_rejected',
|
||||
'pending_apple_release',
|
||||
'pending_developer_release',
|
||||
'prepare_for_submission',
|
||||
'processing_for_distribution',
|
||||
'ready_for_distribution',
|
||||
'ready_for_review',
|
||||
'rejected',
|
||||
'replaced_with_new_version',
|
||||
'waiting_for_export_compliance',
|
||||
'waiting_for_review',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
cf_bundle_version: z.string().optional(),
|
||||
build: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
external_beta: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum([
|
||||
'processing',
|
||||
'processing_exception',
|
||||
'missing_export_compliance',
|
||||
'ready_for_beta_testing',
|
||||
'in_beta_testing',
|
||||
'expired',
|
||||
'ready_for_beta_submission',
|
||||
'in_export_compliance_review',
|
||||
'waiting_for_beta_review',
|
||||
'in_beta_review',
|
||||
'beta_rejected',
|
||||
'beta_approved',
|
||||
]),
|
||||
})
|
||||
.optional(),
|
||||
beta_feedback: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
type: z.enum(['crash', 'screenshot']),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -259,6 +325,8 @@ 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(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
@@ -41,6 +41,7 @@ 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',
|
||||
@@ -108,6 +109,7 @@ 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',
|
||||
@@ -172,6 +174,7 @@ 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',
|
||||
|
||||
+153
-93
@@ -84,6 +84,7 @@ eas --help COMMAND
|
||||
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
|
||||
* [`eas channel:delete [NAME]`](#eas-channeldelete-name)
|
||||
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
|
||||
* [`eas channel:insights`](#eas-channelinsights)
|
||||
* [`eas channel:list`](#eas-channellist)
|
||||
* [`eas channel:pause [NAME]`](#eas-channelpause-name)
|
||||
* [`eas channel:resume [NAME]`](#eas-channelresume-name)
|
||||
@@ -133,6 +134,7 @@ eas --help COMMAND
|
||||
* [`eas update:configure`](#eas-updateconfigure)
|
||||
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
|
||||
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
|
||||
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
|
||||
* [`eas update:list`](#eas-updatelist)
|
||||
* [`eas update:republish`](#eas-updaterepublish)
|
||||
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
|
||||
@@ -178,7 +180,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +197,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +219,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +236,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +250,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +302,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +323,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +343,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +363,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +386,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +424,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +444,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +462,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +482,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -488,7 +490,7 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
|
||||
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found] [--skip-bundler]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
@@ -496,12 +498,13 @@ FLAGS
|
||||
build profile will be selected by default.
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
|
||||
--skip-bundler Install and run the development build without starting the bundler server.
|
||||
|
||||
DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,7 +525,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,7 +563,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,7 +603,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,7 +633,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,7 +659,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,7 +711,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,7 +729,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,7 +747,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,7 +764,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,7 +785,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,7 +806,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,7 +828,31 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas channel:insights --channel <value> --runtime-version <value> [--days <value> | --start <value> | --end
|
||||
<value>] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--channel=<value> (required) Name of the channel.
|
||||
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
|
||||
--end=<value> End of insights time range (ISO date).
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--runtime-version=<value> (required) Runtime version to query insights for.
|
||||
--start=<value> Start of insights time range (ISO date).
|
||||
|
||||
DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,7 +872,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,7 +894,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,7 +916,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,7 +951,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,7 +974,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,7 +995,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +1012,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,7 +1030,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,7 +1059,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,7 +1084,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,7 +1105,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,7 +1126,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,7 +1163,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,7 +1183,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,7 +1204,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,7 +1225,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,7 +1239,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1253,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,7 +1285,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,7 +1311,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,7 +1333,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,7 +1361,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1389,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,7 +1412,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,7 +1434,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,7 +1468,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,7 +1512,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,7 +1543,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,7 +1652,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1633,16 +1660,17 @@ generate the local store configuration from the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:pull [-e <value>]
|
||||
$ eas metadata:pull [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1650,16 +1678,17 @@ sync the local store configuration to the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:push [-e <value>]
|
||||
$ eas metadata:push [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1711,7 +1740,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1725,7 +1754,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1748,7 +1777,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1772,7 +1801,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1790,7 +1819,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1823,7 +1852,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1866,7 +1895,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1887,7 +1916,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1908,7 +1937,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1933,7 +1962,34 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:insights GROUPID [--platform ios|android] [--days <value> | --start <value> | --end <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
GROUPID The ID of an update group.
|
||||
|
||||
FLAGS
|
||||
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
|
||||
--end=<value> End of insights time range (ISO date).
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--platform=<option> Filter to a single platform.
|
||||
<options: ios|android>
|
||||
--start=<value> Start of insights time range (ISO date).
|
||||
|
||||
DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1960,7 +2016,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1998,7 +2054,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2026,7 +2082,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2056,7 +2112,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2077,7 +2133,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2085,19 +2141,23 @@ update group details
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:view GROUPID [--json]
|
||||
$ eas update:view GROUPID [--insights] [--days <value> | --start <value> | --end <value>] [--json]
|
||||
|
||||
ARGUMENTS
|
||||
GROUPID The ID of an update group.
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--days=<value> Show insights from the last N days (default 7). Only used with --insights.
|
||||
--end=<value> End of insights time range (ISO date). Only used with --insights.
|
||||
--insights Also show insights (launches, crash rate, unique users, payload size) for the update group.
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--start=<value> Start of insights time range (ISO date). Only used with --insights.
|
||||
|
||||
DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2118,7 +2178,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2140,7 +2200,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2160,7 +2220,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2179,7 +2239,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2202,7 +2262,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2219,7 +2279,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2340,7 +2400,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2360,7 +2420,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2384,7 +2444,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2424,7 +2484,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2446,7 +2506,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2474,7 +2534,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2494,7 +2554,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2516,5 +2576,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+3414
-551
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.4.0",
|
||||
"version": "18.8.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -43,15 +43,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.15",
|
||||
"@expo/apple-utils": "2.1.19",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-json": "18.4.0",
|
||||
"@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/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.4.0",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -204,6 +204,9 @@
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"observe": {
|
||||
"description": "monitor app performance metrics"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
|
||||
@@ -315,6 +315,42 @@
|
||||
"liveEdits": true,
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"screenshots": {
|
||||
"type": "object",
|
||||
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previews": {
|
||||
"type": "object",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Video file path (relative to project root)"
|
||||
},
|
||||
"previewFrameTimeCode": {
|
||||
"type": "string",
|
||||
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -479,6 +515,57 @@
|
||||
"lootBox": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
|
||||
},
|
||||
"advertising": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app display advertising?"
|
||||
},
|
||||
"ageAssurance": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app implement age assurance mechanisms?"
|
||||
},
|
||||
"ageRatingOverrideV2": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleAgeRatingOverrideV2"
|
||||
}
|
||||
],
|
||||
"description": "Override the age rating using the V2 rating system. Allows setting a more granular age rating than the auto-calculated value."
|
||||
},
|
||||
"developerAgeRatingInfoUrl": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
],
|
||||
"description": "URL to additional developer-provided information about the app's age rating."
|
||||
},
|
||||
"gunsOrOtherWeapons": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain depictions of guns or other weapons?"
|
||||
},
|
||||
"healthOrWellnessTopics": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain health or wellness topics?"
|
||||
},
|
||||
"messagingAndChat": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain messaging or chat functionality?"
|
||||
},
|
||||
"parentalControls": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app implement parental controls?"
|
||||
},
|
||||
"userGeneratedContent": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain or display user-generated content?"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -601,6 +688,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClip": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target.",
|
||||
"properties": {
|
||||
"defaultExperience": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipDefaultExperience",
|
||||
"description": "The default experience for this App Clip. There is exactly one per app."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipDefaultExperience": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Default experience for an App Clip - the configuration shown when invoked without a custom experience.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": ["OPEN", "VIEW", "PLAY"],
|
||||
"description": "Action button shown in the App Clip card."
|
||||
},
|
||||
"releaseWithAppStoreVersion": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to release this default experience alongside the next App Store version."
|
||||
},
|
||||
"reviewDetail": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipReviewDetail",
|
||||
"description": "App Store review information for the App Clip default experience."
|
||||
},
|
||||
"info": {
|
||||
"$ref": "#/definitions/apple/AppleAppClipInfo",
|
||||
"description": "Localized subtitle and header image for the App Clip default experience."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipReviewDetail": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App Store review detail for an App Clip default experience.",
|
||||
"required": ["invocationUrls"],
|
||||
"properties": {
|
||||
"invocationUrls": {
|
||||
"type": "array",
|
||||
"description": "Invocation URLs that App Review will use to launch the App Clip during review.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipLocalizedInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Localized subtitle and header image for an App Clip default experience.",
|
||||
"properties": {
|
||||
"subtitle": {
|
||||
"type": "string",
|
||||
"description": "Subtitle shown in the App Clip card. Apple limits this to 43 characters.",
|
||||
"maxLength": 43
|
||||
},
|
||||
"headerImage": {
|
||||
"type": "string",
|
||||
"description": "Relative path (from project root) to the App Clip header image PNG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAppClipInfo": {
|
||||
"type": "object",
|
||||
"description": "Localized App Clip information, keyed by locale.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ar-SA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Arabic" },
|
||||
"ca": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Catalan" },
|
||||
"zh-Hans": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Simplified)" },
|
||||
"zh-Hant": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Traditional)" },
|
||||
"hr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Croatian" },
|
||||
"cs": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Czech" },
|
||||
"da": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Danish" },
|
||||
"nl-NL": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Dutch" },
|
||||
"en-AU": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Australia)" },
|
||||
"en-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Canada)" },
|
||||
"en-GB": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.K.)" },
|
||||
"en-US": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.S.)" },
|
||||
"fi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Finnish" },
|
||||
"fr-FR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French" },
|
||||
"fr-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French (Canada)" },
|
||||
"de-DE": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "German" },
|
||||
"el": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Greek" },
|
||||
"he": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hebrew" },
|
||||
"hi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hindi" },
|
||||
"hu": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hungarian" },
|
||||
"id": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Indonesian" },
|
||||
"it": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Italian" },
|
||||
"ja": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Japanese" },
|
||||
"ko": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Korean" },
|
||||
"ms": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Malay" },
|
||||
"no": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Norwegian" },
|
||||
"pl": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Polish" },
|
||||
"pt-BR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Brazil)" },
|
||||
"pt-PT": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Portugal)" },
|
||||
"ro": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Romanian" },
|
||||
"ru": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Russian" },
|
||||
"sk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Slovak" },
|
||||
"es-MX": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Mexico)" },
|
||||
"es-ES": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Spain)" },
|
||||
"sv": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Swedish" },
|
||||
"th": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Thai" },
|
||||
"tr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Turkish" },
|
||||
"uk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Ukrainian" },
|
||||
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
|
||||
}
|
||||
},
|
||||
"AppleAgeRatingOverride": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
@@ -614,6 +813,16 @@
|
||||
"FIFTEEN_PLUS",
|
||||
"NINETEEN_PLUS"
|
||||
]
|
||||
},
|
||||
"AppleAgeRatingOverrideV2": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"NINE_PLUS",
|
||||
"THIRTEEN_PLUS",
|
||||
"SIXTEEN_PLUS",
|
||||
"EIGHTEEN_PLUS",
|
||||
"UNRATED"
|
||||
]
|
||||
}
|
||||
},
|
||||
"AppleConfig": {
|
||||
@@ -821,6 +1030,10 @@
|
||||
"review": {
|
||||
"$ref": "#/definitions/apple/AppleReview",
|
||||
"description": "Info for the App Store reviewer"
|
||||
},
|
||||
"appClip": {
|
||||
"$ref": "#/definitions/apple/AppleAppClip",
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user