mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
150b8c5cdf | ||
|
|
f256e1f044 | ||
|
|
9f5e8d5547 |
No files matched your search
@@ -1,11 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "eas-cli"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
mise trust
|
||||
mise install
|
||||
yarn
|
||||
yarn typecheck
|
||||
'''
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@v2.8.2
|
||||
- uses: mshick/add-pr-comment@v2.1.0
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"packages/eas-cli/README.md",
|
||||
"packages/eas-cli/schema/**",
|
||||
"packages/eas-cli/graphql.schema.json",
|
||||
"packages/eas-cli/src/graphql/generated.ts",
|
||||
"packages/build-tools/schema.graphql"
|
||||
]
|
||||
}
|
||||
@@ -3,4 +3,3 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
- Before committing, run the formatter on modified files.
|
||||
+2
-15
@@ -8,26 +8,12 @@ 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] 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))
|
||||
- Add `--skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
|
||||
|
||||
### 🎉 New features
|
||||
@@ -1689,6 +1675,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
|
||||
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.4.0",
|
||||
"version": "18.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-2
@@ -17,7 +17,7 @@
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
@@ -30,7 +30,6 @@
|
||||
"oxfmt": "0.28.0",
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.4.0",
|
||||
"version": "18.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,12 +24,12 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"test": "yarn jest-unit"
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@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/steps": "18.2.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
@@ -75,7 +75,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@expo/repack-app": "~0.2.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -91,7 +91,6 @@
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
|
||||
@@ -67,26 +67,28 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
logLines: string[],
|
||||
errorContext: ErrorContext,
|
||||
buildLogsDirectory: string
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const { phase } = errorContext;
|
||||
if (error instanceof errors.ExpoError) {
|
||||
error.buildPhase ??= phase;
|
||||
if (error instanceof errors.BuildError) {
|
||||
return error;
|
||||
}
|
||||
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
|
||||
const userError =
|
||||
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
error instanceof errors.UserError
|
||||
? error
|
||||
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase));
|
||||
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
|
||||
|
||||
const trackingCode =
|
||||
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
|
||||
|
||||
return new errors.UserError(userError.errorCode, userError.message, {
|
||||
return new errors.BuildError(userError.message, {
|
||||
errorCode: userError.errorCode,
|
||||
trackingCode,
|
||||
docsUrl: userError.docsUrl,
|
||||
cause: error,
|
||||
buildPhase: phase,
|
||||
metadata: buildError?.metadata,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -14,12 +14,8 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
@@ -85,12 +81,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
|
||||
@@ -103,7 +93,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -206,12 +196,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
const appConfig = ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -121,13 +121,12 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
|
||||
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
|
||||
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
throw new Error(
|
||||
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
|
||||
);
|
||||
}
|
||||
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
throw new Error(
|
||||
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
@@ -175,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
}
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
public get appConfig(): ExpoConfig {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).then(config => config.exp);
|
||||
}).exp;
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
@@ -287,7 +287,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private async handleBuildPhaseErrorAsync(
|
||||
err: any,
|
||||
buildPhase: BuildPhase
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const buildError = await resolveBuildPhaseErrorAsync(
|
||||
err,
|
||||
this.logBuffer.getPhaseLogs(buildPhase),
|
||||
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'] : [];
|
||||
|
||||
@@ -51,6 +51,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Simulator`,
|
||||
command: `
|
||||
# shopt -s nullglob is necessary not to try to install
|
||||
@@ -96,6 +97,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Emulator`,
|
||||
command: `
|
||||
# shopt -s globstar is necessary to add /**/ support
|
||||
@@ -132,6 +134,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'maestro_test',
|
||||
ifCondition: '${ always() }',
|
||||
displayName: `maestro test ${flowPath}`,
|
||||
command: `maestro test ${flowPath}`,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -177,34 +177,6 @@ describe(createInternalEasMaestroTestFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -217,217 +217,6 @@ describe(parseMaestroResults, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: login FAILED
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: login PASSED
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
|
||||
// Should return 2 results — one per attempt
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Timeout',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 3000,
|
||||
retryCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: home FAILED, login PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: both PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
|
||||
|
||||
// 4 results: 2 flows × 2 attempts
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: all passed across shards
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
|
||||
vol.fromJSON({
|
||||
// Single overwritten JUnit (only has final attempt's results)
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// 2 timestamp dirs — both flows appear in both (entire suite retried)
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
|
||||
// Both flows have 2 occurrences → retryCount = 1 for both
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit in temp dir (per-flow files)
|
||||
|
||||
@@ -238,45 +238,6 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends per-attempt results with same name but different retryCount', async () => {
|
||||
vol.fromJSON({
|
||||
// Two JUnit files for same flow (per-attempt)
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults).toHaveLength(2);
|
||||
expect(variables.input.testCaseResults[0]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
|
||||
);
|
||||
expect(variables.input.testCaseResults[1]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default directories when inputs are not provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
|
||||
|
||||
@@ -26,7 +26,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
cloneAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -35,15 +34,14 @@ const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
@@ -59,9 +57,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
@@ -101,12 +97,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-2222',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
@@ -157,24 +147,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-base',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-2',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -202,14 +174,4 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips animation scale adjustments when opt out env var is disabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
|
||||
@@ -59,20 +58,6 @@ describe(isMissingPurposeStringError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isSdkVersionIssueError, () => {
|
||||
it('returns true when all errors are SDK-version-issue codes', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isSdkVersionIssueError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
|
||||
@@ -34,20 +34,18 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs, outputs }) => {
|
||||
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: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const appConfig = 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: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
|
||||
const platform =
|
||||
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
|
||||
|
||||
@@ -42,20 +42,18 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
|
||||
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
|
||||
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;
|
||||
const appConfig = 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;
|
||||
|
||||
const channelInput = inputs.channel.value as string | undefined;
|
||||
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -214,17 +214,18 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
|
||||
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If output_format is empty or noop, we won't use this.
|
||||
const outputPath = path.join(
|
||||
maestroReportsDir,
|
||||
[
|
||||
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
|
||||
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -275,11 +276,12 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
|
||||
if (logsResult?.ok) {
|
||||
try {
|
||||
const extension = path.extname(logsResult.value.outputPath);
|
||||
const destinationPath = path.join(
|
||||
deviceLogsDir,
|
||||
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
|
||||
);
|
||||
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
|
||||
|
||||
await fs.promises.rm(destinationPath, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
|
||||
|
||||
@@ -38,79 +38,6 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// Internal helper — not exported. Parses a single JUnit XML file.
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const suite of testsuites) {
|
||||
const testcases = suite?.testcase;
|
||||
if (!Array.isArray(testcases)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tc of testcases) {
|
||||
const name = tc['@_name'];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
const properties: Record<string, string> = {};
|
||||
for (const prop of rawProperties) {
|
||||
const propName = prop['@_name'];
|
||||
const value = prop['@_value'];
|
||||
if (typeof propName !== 'string' || typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
properties[propName] = value;
|
||||
}
|
||||
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -125,9 +52,83 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
}
|
||||
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
|
||||
for (const xmlFile of xmlFiles) {
|
||||
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
|
||||
try {
|
||||
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const suite of testsuites) {
|
||||
const testcases = suite?.testcase;
|
||||
if (!Array.isArray(testcases)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tc of testcases) {
|
||||
const name = tc['@_name'];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
// Use @_status as primary indicator (more robust than checking <failure> presence)
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// Extract error message from <failure> or <error> elements
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
|
||||
// Extract properties
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
const properties: Record<string, string> = {};
|
||||
|
||||
for (const prop of rawProperties) {
|
||||
const propName = prop['@_name'];
|
||||
const value = prop['@_value'];
|
||||
if (typeof propName !== 'string' || typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
properties[propName] = value;
|
||||
}
|
||||
|
||||
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -166,32 +167,9 @@ export async function parseMaestroResults(
|
||||
testsDirectory: string,
|
||||
projectRoot: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files, tracking which file each result came from
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface JUnitResultWithSource {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
const junitResultsWithSource: JUnitResultWithSource[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
junitResultsWithSource.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
|
||||
if (junitResultsWithSource.length === 0) {
|
||||
// 1. Parse JUnit XML files (primary source)
|
||||
const junitResults = await parseJUnitTestCases(junitDirectory);
|
||||
if (junitResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -239,63 +217,25 @@ export async function parseMaestroResults(
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
// Parse attempt index from filename pattern: *-attempt-N.*
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
// Group results by flow name
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
const group = resultsByName.get(entry.result.name) ?? [];
|
||||
group.push(entry);
|
||||
resultsByName.set(entry.result.name, group);
|
||||
}
|
||||
|
||||
for (const [flowName, flowEntries] of resultsByName) {
|
||||
const flowFilePath = flowPathMap.get(flowName);
|
||||
for (const junit of junitResults) {
|
||||
const flowFilePath = flowPathMap.get(junit.name);
|
||||
const relativePath = flowFilePath
|
||||
? await relativizePathAsync(flowFilePath, projectRoot)
|
||||
: flowName;
|
||||
: junit.name; // fallback: use flow name if ai-*.json not found
|
||||
|
||||
if (flowEntries.length === 1) {
|
||||
// Single result for this flow — use ai-*.json occurrence count for retryCount
|
||||
// (backward compat with old-style single JUnit file that gets overwritten)
|
||||
const { result } = flowEntries[0];
|
||||
const occurrences = flowOccurrences.get(flowName) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
const occurrences = flowOccurrences.get(junit.name) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
} else {
|
||||
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
name: junit.name,
|
||||
path: relativePath,
|
||||
status: junit.status,
|
||||
errorMessage: junit.errorMessage,
|
||||
duration: junit.duration,
|
||||
retryCount,
|
||||
tags: junit.tags,
|
||||
properties: junit.properties,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -23,7 +23,6 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -52,11 +51,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'js_bundle_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -88,10 +82,7 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({
|
||||
verbose,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
|
||||
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
|
||||
const workingDirectory = path.join(tmpDir, 'working-directory');
|
||||
@@ -107,20 +98,13 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
sourcemapOutput: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
version: resolvedRepackVersion,
|
||||
version: inputs.repack_version.value as string,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
@@ -134,7 +118,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
@@ -163,7 +146,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
androidSigningOptions,
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
|
||||
@@ -64,21 +64,14 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
|
||||
// share the same name (Maestro config override), which we can't disambiguate.
|
||||
// Same name with different retryCount is expected (per-attempt results from retries).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
// Maestro allows overriding flow names via config, so different flow files can share
|
||||
// the same name. JUnit XML only contains names (not file paths), making it impossible
|
||||
// to map duplicates back to their original flow files. Skip and let the user fix it.
|
||||
const names = flowResults.map(r => r.name);
|
||||
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
@@ -19,7 +16,6 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
@@ -54,15 +50,6 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -189,106 +176,6 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
[
|
||||
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
|
||||
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
|
||||
'',
|
||||
'beforeSettings { settings ->',
|
||||
' try {',
|
||||
' settings.caches {',
|
||||
' cleanup = Cleanup.ALWAYS',
|
||||
' buildCache {',
|
||||
' setRemoveUnusedEntriesAfterDays(3)',
|
||||
' }',
|
||||
' }',
|
||||
' println "[EAS] Configured Gradle cache cleanup via init script"',
|
||||
' } catch (Exception e) {',
|
||||
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'gradle.buildFinished {',
|
||||
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore Gradle cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheStatsAsync({
|
||||
logger,
|
||||
env,
|
||||
|
||||
@@ -10,13 +10,8 @@ import {
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -51,15 +46,6 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -149,66 +135,3 @@ export async function saveCcacheAsync({
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
} catch {
|
||||
logger.warn('No Gradle build cache found, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Saving Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Gradle cache');
|
||||
}
|
||||
}
|
||||
@@ -57,15 +57,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
@@ -116,13 +107,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
@@ -207,13 +191,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: cloneSerialId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -266,31 +266,12 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isSdkVersionIssueError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
|
||||
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
|
||||
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
? ipaInfoResult.value.bundleIdentifier
|
||||
: null;
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Ok to fail, this is just trying to be helpful.
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
@@ -298,12 +279,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
|
||||
'\n\nOther App Store Connect apps visible to this API key:\n' +
|
||||
(visibleAppsSummary ?? ' (unable to retrieve)'),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
@@ -355,10 +331,6 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
|
||||
);
|
||||
}
|
||||
|
||||
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
|
||||
@@ -34,13 +34,11 @@ export namespace AscApiUtils {
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
|
||||
} catch {
|
||||
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
|
||||
@@ -110,26 +108,19 @@ export namespace AscApiUtils {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppsAsync({
|
||||
client,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'getAsync'>;
|
||||
limit?: number;
|
||||
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: limit ?? 10,
|
||||
});
|
||||
return appsResponse.data;
|
||||
async function getVisibleAppsSummaryAsync(
|
||||
client: Pick<AscApiClient, 'getAsync'>
|
||||
): Promise<string | null> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: 10,
|
||||
});
|
||||
if (appsResponse.data.length === 0) {
|
||||
return ' (none)';
|
||||
}
|
||||
|
||||
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
|
||||
return (
|
||||
apps
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n') || ' (none)'
|
||||
);
|
||||
}
|
||||
return appsResponse.data
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
message: expect.stringContaining(
|
||||
'App Store Connect app for application identifier 1234567890 was not found'
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -96,31 +96,6 @@ describe('AscApiUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAppsList', () => {
|
||||
it('formats visible apps', () => {
|
||||
expect(
|
||||
AscApiUtils.formatAppsList([
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
{
|
||||
type: 'apps',
|
||||
id: '2222222222',
|
||||
attributes: { name: 'Second App', bundleId: 'com.second.app' },
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a placeholder when no visible apps are found', () => {
|
||||
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserError when ASC duplicate version error is returned', async () => {
|
||||
const payload = {
|
||||
|
||||
@@ -391,38 +391,6 @@ export namespace AndroidEmulatorUtils {
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableWindowAndTransitionAnimationsAsync({
|
||||
serialId,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
logger.info('Disabling Android emulator window animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
|
||||
}
|
||||
|
||||
logger.info('Disabling Android emulator transition animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
|
||||
}
|
||||
}
|
||||
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
@@ -111,87 +110,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
|
||||
it('sets window and transition animation scales to zero', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Disabling Android emulator window animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'window_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Disabling Android emulator transition animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'transition_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and swallows failures when disabling animations', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('window failed'))
|
||||
.mockRejectedValueOnce(new Error('transition failed'));
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'window failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator window animations.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'transition failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator transition animations.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.stopAsync, () => {
|
||||
it('kills emulator and waits for it to detach', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { readAppConfig } from '../appConfig';
|
||||
|
||||
jest.mock('@expo/env', () => ({
|
||||
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
getConfig: jest.fn(() => ({
|
||||
exp: { name: 'fallback-app', slug: 'fallback-app' },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../expoCli');
|
||||
|
||||
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
|
||||
expoCommandAsync: jest.Mock;
|
||||
};
|
||||
|
||||
const { getConfig } = jest.requireMock('@expo/config') as {
|
||||
getConfig: jest.Mock;
|
||||
};
|
||||
|
||||
const { load: loadEnv } = jest.requireMock('@expo/env') as {
|
||||
load: jest.Mock;
|
||||
};
|
||||
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
|
||||
const baseParams = {
|
||||
projectDir: '/project',
|
||||
env: { NODE_ENV: 'production' },
|
||||
logger,
|
||||
};
|
||||
|
||||
describe(readAppConfig, () => {
|
||||
it('returns config from expo CLI when it succeeds', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env: baseParams.env }
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to @expo/config when expo CLI fails', async () => {
|
||||
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
|
||||
expect(getConfig).toHaveBeenCalledWith('/project', {
|
||||
skipSDKVersionRequirement: true,
|
||||
isPublicConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse JSON output from 'expo config'")
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
|
||||
});
|
||||
|
||||
it('loads env vars from dotenv for SDK >= 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
|
||||
|
||||
expect(loadEnv).toHaveBeenCalledWith('/project');
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
|
||||
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load env vars from dotenv for SDK < 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
|
||||
|
||||
expect(loadEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
|
||||
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
|
||||
|
||||
jest.mock('resolve-from', () => ({
|
||||
__esModule: true,
|
||||
default: Object.assign(jest.fn(), {
|
||||
silent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
}));
|
||||
|
||||
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
|
||||
silent: jest.MockedFunction<typeof resolveFrom.silent>;
|
||||
};
|
||||
|
||||
describe(expoCommandAsync, () => {
|
||||
it('resolves expo CLI and spawns with correct args', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli',
|
||||
['config', '--json'],
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
stdio: 'pipe',
|
||||
env: { FOO: 'bar', EXPO_DEBUG: '0' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: {} });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli.js',
|
||||
['config'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
const moduleError = new Error('Cannot find module');
|
||||
(moduleError as any).code = 'MODULE_NOT_FOUND';
|
||||
mockedResolveFrom.mockImplementation(() => {
|
||||
throw moduleError;
|
||||
});
|
||||
|
||||
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
|
||||
ExpoCLIModuleNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides EXPO_DEBUG to 0', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../packageManager';
|
||||
|
||||
@@ -71,33 +68,6 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
it('should resolve version from package name and dist tag', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'latest',
|
||||
});
|
||||
expect(semver.valid(version)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return null if version cannot be resolved', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'nonexistent-dist-tag',
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(findPackagerRootDir, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -38,7 +38,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -52,7 +52,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -66,7 +66,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
|
||||
@@ -3,70 +3,18 @@ import { Env } from '@expo/eas-build-job';
|
||||
import { load } from '@expo/env';
|
||||
import { LoggerLevel, bunyan } from '@expo/logger';
|
||||
import semver from 'semver';
|
||||
import { expoCommandAsync } from './expoCli';
|
||||
|
||||
interface ReadAppConfigParams {
|
||||
export function readAppConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
sdkVersion,
|
||||
}: {
|
||||
projectDir: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
sdkVersion?: string;
|
||||
}
|
||||
|
||||
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const shouldLoadEnvVarsFromDotenvFile =
|
||||
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
|
||||
if (shouldLoadEnvVarsFromDotenvFile) {
|
||||
const envVarsFromDotenvFile = load(params.projectDir) as Env;
|
||||
const env = { ...params.env, ...envVarsFromDotenvFile };
|
||||
params = { ...params, env };
|
||||
}
|
||||
|
||||
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
|
||||
try {
|
||||
return await getAppConfigFromExpo(params);
|
||||
} catch (error: any) {
|
||||
params.logger.warn(
|
||||
'Failed to read the app config file with `expo config` command:\n' +
|
||||
`${error?.message || error}`
|
||||
);
|
||||
}
|
||||
|
||||
// If this fails, we fall back to directly using `@expo/config`
|
||||
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
|
||||
return getAppConfigFromExpoConfig(params);
|
||||
}
|
||||
|
||||
async function getAppConfigFromExpo({
|
||||
projectDir,
|
||||
env,
|
||||
}: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const result = await expoCommandAsync(
|
||||
projectDir,
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env }
|
||||
);
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!('exp' in parsed)) {
|
||||
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getAppConfigFromExpoConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
}: ReadAppConfigParams): ProjectConfig {
|
||||
}): ProjectConfig {
|
||||
const originalProcessExit = process.exit;
|
||||
const originalProcessCwd = process.cwd;
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
@@ -74,8 +22,11 @@ function getAppConfigFromExpoConfig({
|
||||
const originalProcessEnv = process.env;
|
||||
|
||||
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
|
||||
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
|
||||
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
|
||||
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
|
||||
try {
|
||||
process.env = env;
|
||||
process.env = newEnvsToUse;
|
||||
process.exit = () => {
|
||||
throw new Error('Failed to evaluate app config file');
|
||||
};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
export class ExpoCLIModuleNotFoundError extends Error {}
|
||||
|
||||
function resolveExpoCLI(projectRoot: string): string {
|
||||
try {
|
||||
return (
|
||||
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e.code === 'MODULE_NOT_FOUND') {
|
||||
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expoCommandAsync(
|
||||
projectDir: string,
|
||||
args: string[],
|
||||
options: Omit<SpawnOptions, 'cwd'>
|
||||
) {
|
||||
const expoCliPath = resolveExpoCLI(projectDir);
|
||||
return spawnAsync(expoCliPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
|
||||
// that can stop the command from being readable
|
||||
EXPO_DEBUG: '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
);
|
||||
}
|
||||
|
||||
if (await isEASUpdateConfigured(ctx)) {
|
||||
if (isEASUpdateConfigured(ctx)) {
|
||||
if (ctx.job.updates?.channel !== undefined) {
|
||||
await configureEASExpoUpdatesAsync(ctx);
|
||||
} else {
|
||||
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
} else if (isDevelopmentClient) {
|
||||
// NO-OP: Development clients don't need to have a channel set
|
||||
} else {
|
||||
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
|
||||
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
|
||||
const jobProfile = ctx.job.buildProfile ?? null;
|
||||
ctx.logger.warn(
|
||||
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
|
||||
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
|
||||
const rawUrl = (await ctx.appConfig).updates?.url;
|
||||
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
|
||||
const rawUrl = ctx.appConfig.updates?.url;
|
||||
if (!rawUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
|
||||
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
|
||||
|
||||
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
|
||||
const packagerRunDir = findPackagerRootDir(workingDirectory);
|
||||
const manager = PackageManagerUtils.createForProject(packagerRunDir);
|
||||
const lockPath = path.join(packagerRunDir, manager.lockFile);
|
||||
|
||||
try {
|
||||
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
@@ -28,34 +27,6 @@ export function resolvePackageManager(directory: string): PackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of a package from the dist-tags.
|
||||
* Returns null if the version cannot be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName,
|
||||
distTag,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
packageName: string;
|
||||
distTag: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const distTags = JSON.parse(stdout);
|
||||
if (distTag in distTags) {
|
||||
return distTags[distTag];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findPackagerRootDir(currentDir: string): string {
|
||||
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.4.0",
|
||||
"version": "18.2.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.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -33,7 +33,6 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
|
||||
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.4.0",
|
||||
"version": "18.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -19,10 +19,10 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
@@ -34,7 +34,6 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ErrorCode, ExpoError, SystemError, UserError } from '../errors';
|
||||
import { BuildError, ExpoError, UserError } from '../errors';
|
||||
import { BuildPhase } from '../logs';
|
||||
|
||||
type ExpoErrorDetails = ConstructorParameters<typeof ExpoError>[1];
|
||||
|
||||
class TestExpoError extends ExpoError {
|
||||
constructor(message: string, details: ExpoErrorDetails) {
|
||||
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
|
||||
super(message, details);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +27,27 @@ describe(ExpoError, () => {
|
||||
});
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.toExternalExpoError()).toEqual({
|
||||
expect(error.format()).toEqual({
|
||||
errorCode: 'ERR_CODE',
|
||||
message: 'canonical message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(BuildError, () => {
|
||||
it('formats using canonical message and errorCode', () => {
|
||||
const error = new BuildError('canonical message', {
|
||||
errorCode: 'ERR_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@expo/config' },
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.metadata).toEqual({ packageName: '@expo/config' });
|
||||
expect(error.format()).toEqual({
|
||||
errorCode: 'ERR_CODE',
|
||||
message: 'canonical message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
@@ -56,16 +74,6 @@ describe(UserError, () => {
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('supports trackingCode and buildPhase in options', () => {
|
||||
const error = new UserError('ERR_CODE', 'message', {
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
});
|
||||
|
||||
it('supports docsUrl, metadata, and cause in options', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new UserError('ERR_CODE', 'message', {
|
||||
@@ -79,30 +87,3 @@ describe(UserError, () => {
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SystemError, () => {
|
||||
it('always formats with SERVER_ERROR and preserves trackingCode', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new SystemError('system message', {
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@expo/config' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.errorCode).toBe(ErrorCode.SERVER_ERROR);
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({ packageName: '@expo/config' });
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.toExternalExpoError()).toEqual({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
message: 'system message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,10 +54,7 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
|
||||
this.buildPhase = details.buildPhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized error payload used by the orchestrator-worker API.
|
||||
*/
|
||||
public toExternalExpoError(): ExternalBuildError {
|
||||
public format(): ExternalBuildError {
|
||||
return {
|
||||
errorCode: this.errorCode,
|
||||
message: this.message,
|
||||
@@ -67,49 +64,29 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
|
||||
}
|
||||
}
|
||||
|
||||
export class BuildError<
|
||||
TMetadata extends ErrorMetadata = ErrorMetadata,
|
||||
> extends ExpoError<TMetadata> {
|
||||
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
|
||||
super(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError<
|
||||
TMetadata extends ErrorMetadata = ErrorMetadata,
|
||||
> extends ExpoError<TMetadata> {
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
message: string,
|
||||
public message: string,
|
||||
options?: {
|
||||
trackingCode?: string;
|
||||
docsUrl?: string;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: TMetadata;
|
||||
cause?: unknown;
|
||||
}
|
||||
) {
|
||||
super(message, {
|
||||
errorCode,
|
||||
trackingCode: options?.trackingCode,
|
||||
docsUrl: options?.docsUrl,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError<
|
||||
TMetadata extends ErrorMetadata = ErrorMetadata,
|
||||
> extends ExpoError<TMetadata> {
|
||||
constructor(
|
||||
message: string,
|
||||
options?: {
|
||||
trackingCode?: string;
|
||||
docsUrl?: string;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: TMetadata;
|
||||
cause?: unknown;
|
||||
}
|
||||
) {
|
||||
super(message, {
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
trackingCode: options?.trackingCode,
|
||||
docsUrl: options?.docsUrl,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
@@ -122,25 +99,28 @@ export class UnknownError extends UserError {
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
buildPhase
|
||||
? `Unknown error. See logs of the ${buildPhaseDisplayName[buildPhase]} build phase for more information.`
|
||||
: 'Unknown error. See logs for more information.',
|
||||
{ buildPhase }
|
||||
: 'Unknown error. See logs for more information.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownBuildError extends UserError {
|
||||
export class UnknownBuildError extends BuildError {
|
||||
constructor() {
|
||||
const errorCode = ErrorCode.UNKNOWN_ERROR;
|
||||
const message = 'Unknown error. See logs for more information.';
|
||||
super(errorCode, message);
|
||||
super(message, {
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownCustomBuildError extends UserError {
|
||||
export class UnknownCustomBuildError extends BuildError {
|
||||
constructor() {
|
||||
const errorCode = ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR;
|
||||
const message = 'Unknown custom build error. See logs for more information.';
|
||||
super(errorCode, message);
|
||||
super(message, {
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+93
-94
@@ -178,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +422,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +442,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +460,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -488,20 +488,19 @@ 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]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
emulator/simulator internal distribution dev client builds. The "development-simulator"
|
||||
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.
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
|
||||
emulator/simulator internal distribution dev client builds. The "development-simulator"
|
||||
build profile will be selected by default.
|
||||
-p, --platform=<option> <options: ios|android>
|
||||
|
||||
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.3.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,7 +521,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.3.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,7 +559,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.3.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,7 +599,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.3.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,7 +629,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.3.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,7 +655,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.3.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,7 +707,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.3.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,7 +725,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.3.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,7 +743,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.3.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,7 +760,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.3.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,7 +781,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.3.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,7 +802,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.3.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,7 +824,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,7 +844,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.3.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,7 +866,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.3.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,7 +888,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.3.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,7 +923,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.3.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,7 +946,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.3.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,7 +967,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.3.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +984,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.3.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,7 +1002,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.3.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,7 +1031,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.3.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,7 +1056,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.3.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,7 +1077,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.3.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,7 +1098,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.3.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,7 +1135,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.3.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,7 +1155,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.3.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,7 +1176,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.3.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,7 +1197,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.3.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,7 +1211,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.3.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1225,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.3.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,7 +1257,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.3.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,7 +1283,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.3.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,7 +1305,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.3.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,7 +1333,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.3.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1361,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.3.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,7 +1384,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.3.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,7 +1406,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.3.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,7 +1440,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.3.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,7 +1484,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.3.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,7 +1515,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.3.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,7 +1624,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.3.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1642,7 +1641,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.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.3.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1659,7 +1658,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.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.3.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1711,7 +1710,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.3.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1725,7 +1724,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.3.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1748,7 +1747,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.3.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1772,7 +1771,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.3.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1790,7 +1789,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.3.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1823,7 +1822,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.3.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1866,7 +1865,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.3.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1887,7 +1886,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.3.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1908,7 +1907,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.3.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1933,7 +1932,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1960,7 +1959,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.3.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1998,7 +1997,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.3.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2026,7 +2025,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.3.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2056,7 +2055,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.3.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2077,7 +2076,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.3.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2097,7 +2096,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.3.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2118,7 +2117,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.3.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2140,7 +2139,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.3.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2160,7 +2159,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.3.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2179,7 +2178,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.3.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2202,7 +2201,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.3.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2219,7 +2218,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.3.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2340,7 +2339,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.3.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2360,7 +2359,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.3.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2384,7 +2383,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.3.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2424,7 +2423,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.3.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2446,7 +2445,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.3.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2474,7 +2473,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.3.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2494,7 +2493,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.3.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2516,5 +2515,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.3.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+359
-3067
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
// Simulate an interactive environment so that commands default to interactive mode in tests.
|
||||
// Without this, isNonInteractiveByDefault() returns true because:
|
||||
// 1. Jest runs in a non-TTY environment (!process.stdin.isTTY)
|
||||
// 2. CI environments set CI=1 (boolish('CI', false))
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
delete process.env.CI;
|
||||
|
||||
beforeAll(() => {
|
||||
console.log = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.4.0",
|
||||
"version": "18.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -28,7 +28,7 @@
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
@@ -39,16 +39,16 @@
|
||||
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log *.tsbuildinfo",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.15",
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@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/eas-build-job": "18.2.0",
|
||||
"@expo/eas-json": "18.2.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.0.1",
|
||||
@@ -63,12 +63,11 @@
|
||||
"@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.2.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
"@segment/ajv-human-errors": "^2.1.2",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"ajv": "8.11.0",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowCustomJobFragment,
|
||||
getMockWorkflowRunWithBuildJobsFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
@@ -125,8 +124,7 @@ describe(WorkflowLogView, () => {
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
@@ -134,14 +132,13 @@ describe(WorkflowLogView, () => {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-1' };
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
@@ -151,14 +148,6 @@ describe(WorkflowLogView, () => {
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Install dependencies - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
@@ -170,9 +159,6 @@ describe(WorkflowLogView, () => {
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withBuildJobs: 1 }));
|
||||
jest
|
||||
.mocked(WorkflowRunQuery.withJobsByIdAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunWithBuildJobsFragment());
|
||||
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
|
||||
id: 'build1',
|
||||
status: BuildStatus.Finished,
|
||||
@@ -195,8 +181,7 @@ describe(WorkflowLogView, () => {
|
||||
logFiles: ['https://example.com/log1'],
|
||||
});
|
||||
let promptCalls = 0;
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
@@ -204,14 +189,13 @@ describe(WorkflowLogView, () => {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-1' };
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForBuildJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
@@ -221,65 +205,9 @@ describe(WorkflowLogView, () => {
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'step1 - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
test('view logs keeps duplicate display names separated by step id', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
let stepPrompt: any;
|
||||
jest.mocked(promptAsync).mockImplementation(async prompt => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
stepPrompt = prompt;
|
||||
return { selectedStep: 'step-id-2' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
|
||||
'{"result":"fail","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-2","time":"2022-01-01T00:00:01.000Z","msg":"second"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
await cmd.run();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(stepPrompt.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Install - success',
|
||||
value: 'step-id-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: 'Install - fail',
|
||||
value: 'step-id-2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:01.000Z second');
|
||||
});
|
||||
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
|
||||
@@ -12,8 +12,8 @@ import slash from 'slash';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
import { getActorUsername } from '../../user/User';
|
||||
|
||||
interface JobData {
|
||||
projectArchive: ArchiveSource;
|
||||
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Android.Job> {
|
||||
const username = getActorUsername(ctx.user) ?? undefined;
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
|
||||
@@ -455,9 +455,6 @@ const queueProgressBar = new cliProgress.SingleBar(
|
||||
cliProgress.Presets.rect
|
||||
);
|
||||
|
||||
let statusNewSetAt: number | null = null;
|
||||
const NEW_STATUS_GRACE_PERIOD_MS = 15_000;
|
||||
|
||||
async function handleSingleBuildProgressAsync(
|
||||
{
|
||||
build,
|
||||
@@ -489,19 +486,11 @@ async function handleSingleBuildProgressAsync(
|
||||
case BuildStatus.Finished:
|
||||
spinner.succeed('Build finished');
|
||||
return { refetch: false };
|
||||
case BuildStatus.New: {
|
||||
const now = Date.now();
|
||||
statusNewSetAt ??= now;
|
||||
const newStatusDurationMs = now - statusNewSetAt;
|
||||
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
|
||||
spinner.text = 'Waiting for build to get enqueued…';
|
||||
} else {
|
||||
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}.`;
|
||||
}
|
||||
case BuildStatus.New:
|
||||
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}.`;
|
||||
break;
|
||||
}
|
||||
case BuildStatus.InQueue: {
|
||||
spinner.text = 'Build queued...';
|
||||
const progressBarPayload =
|
||||
@@ -564,7 +553,7 @@ const priorityToQueueDisplayName: Record<BuildPriority, string> = {
|
||||
};
|
||||
|
||||
const statusToDisplayName: Record<BuildStatus, string> = {
|
||||
[BuildStatus.New]: 'waiting to enter the queue',
|
||||
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
|
||||
[BuildStatus.InQueue]: 'in queue',
|
||||
[BuildStatus.InProgress]: 'in progress',
|
||||
[BuildStatus.PendingCancel]: 'canceled',
|
||||
@@ -582,7 +571,6 @@ async function handleMultipleBuildsProgressAsync(
|
||||
const buildCount = maybeBuilds.length;
|
||||
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
|
||||
|
||||
const someNew = builds.some(build => build.status === BuildStatus.New);
|
||||
const allFinished =
|
||||
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
|
||||
const allSettled =
|
||||
@@ -595,10 +583,6 @@ async function handleMultipleBuildsProgressAsync(
|
||||
].includes(build.status)
|
||||
).length === buildCount;
|
||||
|
||||
if (someNew && statusNewSetAt === null) {
|
||||
statusNewSetAt = Date.now();
|
||||
}
|
||||
|
||||
if (allSettled) {
|
||||
if (allFinished) {
|
||||
spinner.succeed(formatSettledBuildsText(builds));
|
||||
@@ -607,11 +591,7 @@ async function handleMultipleBuildsProgressAsync(
|
||||
}
|
||||
return { refetch: false };
|
||||
} else {
|
||||
const showConcurrencyWarning =
|
||||
someNew &&
|
||||
statusNewSetAt !== null &&
|
||||
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
|
||||
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
|
||||
spinner.text = formatPendingBuildsText(originalSpinnerText, builds);
|
||||
return { refetch: true };
|
||||
}
|
||||
}
|
||||
@@ -630,20 +610,12 @@ function formatSettledBuildsText(builds: BuildFragment[]): string {
|
||||
.join('\n ');
|
||||
}
|
||||
|
||||
function formatPendingBuildsText(
|
||||
originalSpinnerText: string,
|
||||
builds: BuildFragment[],
|
||||
showConcurrencyWarning: boolean
|
||||
): string {
|
||||
function formatPendingBuildsText(originalSpinnerText: string, builds: BuildFragment[]): string {
|
||||
return [
|
||||
originalSpinnerText,
|
||||
...platforms.map(platform => {
|
||||
const build = builds.find(build => build.platform === platform);
|
||||
const status = build ? statusToDisplayName[build.status] : 'unknown';
|
||||
const concurrencyWarning =
|
||||
showConcurrencyWarning && build?.status === BuildStatus.New
|
||||
? ' (concurrency limit reached)'
|
||||
: '';
|
||||
let extraInfo = '';
|
||||
if (
|
||||
build?.status === BuildStatus.InQueue &&
|
||||
@@ -663,7 +635,7 @@ function formatPendingBuildsText(
|
||||
}
|
||||
return `${appPlatformEmojis[platform]} ${
|
||||
appPlatformDisplayNames[platform]
|
||||
} build - status: ${chalk.bold(status)}${concurrencyWarning}${extraInfo}`;
|
||||
} build - status: ${chalk.bold(status)}${extraInfo}`;
|
||||
}),
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import slash from 'slash';
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
import { getActorUsername } from '../../user/User';
|
||||
|
||||
interface JobData {
|
||||
projectArchive: ArchiveSource;
|
||||
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Ios.Job> {
|
||||
const username = getActorUsername(ctx.user) ?? undefined;
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
|
||||
@@ -10,12 +10,12 @@ import { LocalBuildMode } from './local';
|
||||
import { BuildDistributionType } from './types';
|
||||
import Log from '../log';
|
||||
import {
|
||||
getUsernameForBuildMetadataAndBuildJob,
|
||||
isExpoUpdatesInstalled,
|
||||
} from '../project/projectUtils';
|
||||
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
|
||||
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getActorUsername } from '../user/User';
|
||||
|
||||
export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>,
|
||||
@@ -49,7 +49,7 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
|
||||
? false
|
||||
: await ctx.vcsClient.hasUncommittedChangesAsync(),
|
||||
username: getActorUsername(ctx.user) ?? undefined,
|
||||
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
|
||||
message: ctx.message,
|
||||
...(ctx.platform === Platform.IOS && {
|
||||
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
|
||||
|
||||
@@ -27,9 +27,7 @@ import {
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import Log, { link } from '../log';
|
||||
import Sentry from '../sentry';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { getActorDisplayName } from '../user/User';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type ContextInput<
|
||||
@@ -171,6 +169,7 @@ export default abstract class EasCommand extends Command {
|
||||
* It is set up here to ensure a consistent setup.
|
||||
*/
|
||||
private analyticsInternal?: AnalyticsWithOrchestration;
|
||||
|
||||
/**
|
||||
* Execute the context in the contextDefinition to satisfy command prerequisites.
|
||||
*/
|
||||
@@ -231,13 +230,7 @@ export default abstract class EasCommand extends Command {
|
||||
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
const actor = await this.sessionManager.getUserAsync();
|
||||
if (actor) {
|
||||
Sentry.setUser({
|
||||
id: actor.id,
|
||||
username: getActorDisplayName(actor),
|
||||
});
|
||||
}
|
||||
await this.sessionManager.getUserAsync();
|
||||
|
||||
this.analytics.logEvent(CommandEvent.ACTION, {
|
||||
// id is assigned by oclif in constructor based on the filepath:
|
||||
@@ -251,13 +244,11 @@ export default abstract class EasCommand extends Command {
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
override async finally(err: Error): Promise<any> {
|
||||
await this.analytics.flushAsync();
|
||||
await Sentry.flush(2000);
|
||||
return await super.finally(err);
|
||||
}
|
||||
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
const commandId = this.id ?? 'unknown';
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
let baseMessage = `${this.id} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
Log.error(err.message);
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
@@ -302,17 +293,6 @@ export default abstract class EasCommand extends Command {
|
||||
Log.error(err.message);
|
||||
}
|
||||
Log.debug(err);
|
||||
Sentry.withScope(scope => {
|
||||
scope.setTag('command', commandId);
|
||||
scope.setTag('error_name', err.name);
|
||||
scope.setExtra('commandId', commandId);
|
||||
scope.setExtra('commandMessage', baseMessage);
|
||||
scope.setExtra('originalMessage', err.message);
|
||||
if (err instanceof CombinedError) {
|
||||
scope.setExtra('graphQLErrorCount', err.graphQLErrors.length);
|
||||
}
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
const sanitizedError = new Error(baseMessage);
|
||||
sanitizedError.stack = err.stack;
|
||||
throw sanitizedError;
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
|
||||
import Log from '../../log';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import EasCommand from '../EasCommand';
|
||||
|
||||
jest.mock('../../user/User');
|
||||
jest.mock('../../user/SessionManager');
|
||||
jest.mock('../../sentry', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
init: jest.fn(),
|
||||
setTag: jest.fn(),
|
||||
setUser: jest.fn(),
|
||||
withScope: jest.fn(),
|
||||
captureException: jest.fn(),
|
||||
flush: jest.fn(async (): Promise<void> => {}),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
const { CommandEvent } = jest.requireActual('../../analytics/AnalyticsManager');
|
||||
return {
|
||||
@@ -44,22 +38,20 @@ const analytics: AnalyticsWithOrchestration = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
|
||||
});
|
||||
|
||||
const createTestEasCommand = (): any => {
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
createAnalyticsAsync.mockResolvedValue(analytics);
|
||||
const EasCommand = require('../EasCommand').default;
|
||||
|
||||
const createTestEasCommand = () => {
|
||||
class TestEasCommand extends EasCommand {
|
||||
async runAsync(): Promise<void> {}
|
||||
override async runAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
describe('EasCommand', () => {
|
||||
describe(EasCommand.name, () => {
|
||||
describe('without exceptions', () => {
|
||||
// The first test in this suite should have an increased timeout
|
||||
// because of the implementation of Command from @oclif/command.
|
||||
@@ -72,7 +64,6 @@ describe('EasCommand', () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
const SessionManager = jest.requireMock('../../user/SessionManager').default;
|
||||
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
|
||||
expect(sessionManagerSpy).toBeCalledTimes(1);
|
||||
}, 60_000);
|
||||
@@ -81,7 +72,6 @@ describe('EasCommand', () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
expect(createAnalyticsAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -92,14 +82,6 @@ describe('EasCommand', () => {
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes Sentry', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
expect(Sentry.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs events', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
@@ -123,59 +105,8 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('captures unexpected errors', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const error = new Error('Unexpected, internal error message');
|
||||
const sentryScope = {
|
||||
setTag: jest.fn(),
|
||||
setExtra: jest.fn(),
|
||||
};
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
Sentry.withScope.mockImplementation((cb: (scope: typeof sentryScope) => void) => {
|
||||
cb(sentryScope);
|
||||
});
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
|
||||
expect(sentryScope.setTag).toHaveBeenCalledWith('error_name', error.name);
|
||||
expect(sentryScope.setExtra).toHaveBeenCalledWith('commandId', TestEasCommand.id);
|
||||
expect(sentryScope.setExtra).toHaveBeenCalledWith(
|
||||
'commandMessage',
|
||||
`${TestEasCommand.id} command failed.`
|
||||
);
|
||||
expect(sentryScope.setExtra).toHaveBeenCalledWith('originalMessage', error.message);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('captures expected command errors', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { EasCommandError } = require('../errors');
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
|
||||
cb({ setTag: jest.fn(), setExtra: jest.fn() });
|
||||
});
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw new EasCommandError('Expected user-facing error');
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs the message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
@@ -193,11 +124,9 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
@@ -213,12 +142,9 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs the cleaned message with request ID if present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const { GraphQLError } = require('graphql/error');
|
||||
const graphQLError = new GraphQLError(
|
||||
'Unexpected GraphQL error message',
|
||||
null,
|
||||
@@ -248,12 +174,9 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const { GraphQLError } = require('graphql/error');
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
|
||||
@@ -304,7 +227,6 @@ describe('EasCommand', () => {
|
||||
it('re-throws the error with a different default base message in case of CombinedError', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
@@ -320,8 +242,6 @@ describe('EasCommand', () => {
|
||||
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const { CombinedError } = require('@urql/core');
|
||||
const { GraphQLError } = require('graphql/error');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
|
||||
@@ -1,46 +1,4 @@
|
||||
import { isNonInteractiveByDefault, resolveNonInteractiveAndJsonFlags } from '../flags';
|
||||
|
||||
describe(isNonInteractiveByDefault, () => {
|
||||
const originalEnv = process.env;
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true });
|
||||
});
|
||||
|
||||
test('returns false when stdin is a TTY and CI is not set', () => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.CI;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
expect(isNonInteractiveByDefault()).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when CI=1', () => {
|
||||
process.env = { ...originalEnv, CI: '1' };
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
expect(isNonInteractiveByDefault()).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when CI=true', () => {
|
||||
process.env = { ...originalEnv, CI: 'true' };
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
expect(isNonInteractiveByDefault()).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when stdin is not a TTY', () => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.CI;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true });
|
||||
expect(isNonInteractiveByDefault()).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true when both CI is set and stdin is not a TTY', () => {
|
||||
process.env = { ...originalEnv, CI: '1' };
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true });
|
||||
expect(isNonInteractiveByDefault()).toBe(true);
|
||||
});
|
||||
});
|
||||
import { resolveNonInteractiveAndJsonFlags } from '../flags';
|
||||
|
||||
describe(resolveNonInteractiveAndJsonFlags, () => {
|
||||
test('returns both false when no flags are set', () => {
|
||||
|
||||
@@ -189,8 +189,6 @@ export async function validateOrSetProjectIdAsync({
|
||||
return user.username;
|
||||
case 'SSOUser':
|
||||
return user.username;
|
||||
case 'PartnerActor':
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
throw new Error(
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
export function isNonInteractiveByDefault(): boolean {
|
||||
return boolish('CI', false) || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
export const EasNonInteractiveAndJsonFlags = {
|
||||
json: Flags.boolean({
|
||||
@@ -12,8 +7,6 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
default: () => Promise.resolve(isNonInteractiveByDefault()),
|
||||
noCacheDefault: true,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -71,11 +64,6 @@ export const EASEnvironmentVariableScopeFlag = {
|
||||
export const EASNonInteractiveFlag = {
|
||||
'non-interactive': Flags.boolean({
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
default: async () => {
|
||||
return isNonInteractiveByDefault();
|
||||
},
|
||||
noCacheDefault: true,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
|
||||
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
|
||||
import { infoForActiveWorkflowRunAsync } from '../utils';
|
||||
import { WorkflowJobStatus } from '../../../graphql/generated';
|
||||
|
||||
jest.mock('../fetchLogs');
|
||||
|
||||
describe('workflow utils', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows the display name for the current step while keying logs by step id', async () => {
|
||||
const workflowRun = getMockWorkflowRunWithJobsFragment();
|
||||
workflowRun.jobs = workflowRun.jobs.map(job => ({
|
||||
...job,
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
}));
|
||||
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
[
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
|
||||
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
|
||||
|
||||
expect(output).toContain('Current step');
|
||||
expect(output).toContain('Install dependencies');
|
||||
expect(output).not.toContain('step-id-1');
|
||||
});
|
||||
});
|
||||
@@ -22,17 +22,6 @@ export async function fetchRawLogsForBuildJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
|
||||
// for both built-in and custom build steps
|
||||
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (turtleLogFileUrl) {
|
||||
const response = await fetch(turtleLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
// Fall back to build logFiles
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
@@ -47,5 +36,6 @@ export async function fetchRawLogsForBuildJobAsync(
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
return await response.text();
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
@@ -50,11 +50,4 @@ export type WorkflowLogLine = {
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
logLines: WorkflowLogLine[];
|
||||
}
|
||||
>;
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
@@ -84,17 +84,16 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.values())
|
||||
.map(({ key, label, logLines }) => {
|
||||
return Array.from(logs.keys())
|
||||
.map(step => {
|
||||
const logLines = logs.get(step);
|
||||
const stepStatus =
|
||||
logLines?.filter(
|
||||
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
|
||||
)[0]?.result ?? '';
|
||||
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
|
||||
return {
|
||||
title: `${label} - ${stepStatus}`,
|
||||
name: label,
|
||||
title: `${step} - ${stepStatus}`,
|
||||
name: step,
|
||||
status: stepStatus,
|
||||
value: key,
|
||||
value: step,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
@@ -140,27 +139,20 @@ export async function fetchAndProcessLogsFromJobAsync(
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
|
||||
parsedLine;
|
||||
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
|
||||
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
|
||||
if (stepKey && stepLabel) {
|
||||
if (!logs.has(stepKey)) {
|
||||
logs.set(stepKey, {
|
||||
key: stepKey,
|
||||
label: stepLabel,
|
||||
logLines: [],
|
||||
});
|
||||
const stepId = buildStepDisplayName ?? buildStepInternalId;
|
||||
if (stepId) {
|
||||
if (!logKeys.has(stepId)) {
|
||||
logKeys.add(stepId);
|
||||
logs.set(stepId, []);
|
||||
}
|
||||
const logGroup = logs.get(stepKey)!;
|
||||
if (buildStepDisplayName) {
|
||||
logGroup.label = buildStepDisplayName;
|
||||
}
|
||||
logGroup.logLines.push({ time, msg, result, marker, err });
|
||||
logs.get(stepId)?.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
@@ -246,7 +238,7 @@ export async function infoForFailedWorkflowRunAsync(
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
if (steps.length > 0) {
|
||||
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
|
||||
const failedStep = steps.find(step => step.status === 'fail');
|
||||
if (failedStep) {
|
||||
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
|
||||
@@ -21,10 +21,6 @@ export default class MetadataPull extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -38,18 +34,18 @@ export default class MetadataPull extends EasCommand {
|
||||
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
|
||||
|
||||
const { flags } = await this.parse(MetadataPull);
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(MetadataPull, {
|
||||
nonInteractive,
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
|
||||
// this command is interactive (all nonInteractive flags passed to utility functions are false)
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
|
||||
|
||||
const submitProfiles = await getProfilesAsync({
|
||||
type: 'submit',
|
||||
@@ -70,7 +66,7 @@ export default class MetadataPull extends EasCommand {
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
nonInteractive,
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
@@ -81,9 +77,6 @@ export default class MetadataPull extends EasCommand {
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile: submitProfile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
const relativePath = path.relative(process.cwd(), filePath);
|
||||
|
||||
|
||||
@@ -19,10 +19,6 @@ export default class MetadataPush extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -36,18 +32,18 @@ export default class MetadataPush extends EasCommand {
|
||||
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
|
||||
|
||||
const { flags } = await this.parse(MetadataPush);
|
||||
const nonInteractive = flags['non-interactive'];
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(MetadataPush, {
|
||||
nonInteractive,
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
|
||||
// this command is interactive (all nonInteractive flags passed to utility functions are false)
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
|
||||
|
||||
const submitProfiles = await getProfilesAsync({
|
||||
type: 'submit',
|
||||
@@ -68,7 +64,7 @@ export default class MetadataPush extends EasCommand {
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
nonInteractive,
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
@@ -79,9 +75,6 @@ export default class MetadataPush extends EasCommand {
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile: submitProfile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -73,12 +73,6 @@ export default class Onboarding extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (actor.__typename === 'PartnerActor') {
|
||||
throw new Error(
|
||||
'This command is not available for partner actors. Make sure you are using a user token and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!actor.preferences.onboarding) {
|
||||
throw new Error(
|
||||
'This command can only be run as part of the onboarding process started on the Expo website. Visit https://expo.new to start a new project.'
|
||||
|
||||
@@ -12,14 +12,14 @@ import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
function printLogsForAllSteps(logs: WorkflowLogs): void {
|
||||
[...logs.values()].forEach(({ label, logLines }) => {
|
||||
if (logLines.length === 0) {
|
||||
return;
|
||||
[...logs.keys()].forEach(step => {
|
||||
const logLines = logs.get(step);
|
||||
if (logLines) {
|
||||
Log.log(`Step: ${step}`);
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
}
|
||||
Log.log(`Step: ${label}`);
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
});
|
||||
}
|
||||
@@ -89,25 +89,21 @@ export default class WorkflowLogView extends EasCommand {
|
||||
if (allSteps) {
|
||||
if (logs) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(
|
||||
Object.fromEntries(
|
||||
Array.from(logs.entries()).map(([key, value]) => [key, value.logLines])
|
||||
)
|
||||
);
|
||||
printJsonOnlyOutput(Object.fromEntries(logs));
|
||||
} else {
|
||||
printLogsForAllSteps(logs);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const selectedStep = finalSelectionState?.step as unknown as string;
|
||||
const logGroup = logs?.get(selectedStep);
|
||||
if (logGroup) {
|
||||
const logLines = logs?.get(selectedStep);
|
||||
if (logLines) {
|
||||
if (flags.json) {
|
||||
const output: { [key: string]: WorkflowLogLine[] | null } = {};
|
||||
output[selectedStep] = logGroup.logLines;
|
||||
output[selectedStep] = logLines ?? null;
|
||||
printJsonOnlyOutput(output);
|
||||
} else {
|
||||
logGroup.logLines.forEach(line => {
|
||||
logLines.forEach(line => {
|
||||
Log.log(` ${line.time} ${line.msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
+8236
-1605
File diff suppressed because it is too large.
Load diff
@@ -43,9 +43,6 @@ export const UserQuery = {
|
||||
id
|
||||
...AccountFragment
|
||||
}
|
||||
... on PartnerActor {
|
||||
username
|
||||
}
|
||||
featureGates
|
||||
isExpoAdmin
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ export const UpdateFragmentNode = gql`
|
||||
... on Robot {
|
||||
firstName
|
||||
}
|
||||
... on PartnerActor {
|
||||
username
|
||||
}
|
||||
}
|
||||
branch {
|
||||
id
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { SubmitProfile } from '@expo/eas-json';
|
||||
|
||||
import { getAppStoreAuthAsync } from '../auth';
|
||||
import { AuthenticationMode } from '../../credentials/ios/appstore/authenticateTypes';
|
||||
|
||||
// Mock all external dependencies
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
App: { findAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
|
||||
getRequestContext: jest.fn(() => ({ token: 'mock-token' })),
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/appstore/resolveCredentials', () => ({
|
||||
hasAscEnvVars: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('../../project/ios/bundleIdentifier', () => ({
|
||||
getBundleIdentifierAsync: jest.fn(() => 'com.example.app'),
|
||||
}));
|
||||
|
||||
jest.mock('../../project/projectUtils', () => ({
|
||||
getOwnerAccountForProjectIdAsync: jest.fn(() => ({
|
||||
id: 'account-id',
|
||||
name: 'testaccount',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../credentials/ios/api/GraphqlClient', () => ({
|
||||
getAscApiKeyForAppSubmissionsAsync: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
readFile: jest.fn(() => 'mock-key-p8-content'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
const { hasAscEnvVars } =
|
||||
require('../../credentials/ios/appstore/resolveCredentials') as jest.Mocked<
|
||||
typeof import('../../credentials/ios/appstore/resolveCredentials')
|
||||
>;
|
||||
const { getAscApiKeyForAppSubmissionsAsync } =
|
||||
require('../../credentials/ios/api/GraphqlClient') as jest.Mocked<
|
||||
typeof import('../../credentials/ios/api/GraphqlClient')
|
||||
>;
|
||||
const { AppStoreConnectApiKeyQuery } =
|
||||
require('../../graphql/queries/AppStoreConnectApiKeyQuery') as jest.Mocked<
|
||||
typeof import('../../graphql/queries/AppStoreConnectApiKeyQuery')
|
||||
>;
|
||||
|
||||
const mockApp = { id: '123', bundleId: 'com.example.app' };
|
||||
|
||||
function createMockCredentialsCtx(ensureAuthResult?: any) {
|
||||
return {
|
||||
appStore: {
|
||||
ensureAuthenticatedAsync: jest.fn(() => ({
|
||||
authState: { context: { token: 'mock-token' } },
|
||||
...ensureAuthResult,
|
||||
})),
|
||||
},
|
||||
vcsClient: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createBaseArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
credentialsCtx: createMockCredentialsCtx(),
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(getAppStoreAuthAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(App.findAsync as jest.Mock).mockResolvedValue(mockApp);
|
||||
hasAscEnvVars.mockReturnValue(false);
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('uses API key auth when ASC env vars are set', async () => {
|
||||
hasAscEnvVars.mockReturnValue(true);
|
||||
const args = createBaseArgs();
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: AuthenticationMode.API_KEY })
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from submit profile when ascApiKeyPath/Id/IssuerId are set', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'KEY123',
|
||||
ascApiKeyIssuerId: 'ISSUER456',
|
||||
} as any;
|
||||
const args = createBaseArgs({ profile });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'mock-key-p8-content',
|
||||
keyId: 'KEY123',
|
||||
issuerId: 'ISSUER456',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from EAS credentials service when available', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
keyIdentifier: 'KEY789',
|
||||
issuerIdentifier: 'ISSUER012',
|
||||
appleTeam: {
|
||||
appleTeamIdentifier: 'TEAM123',
|
||||
appleTeamName: 'Test Team',
|
||||
},
|
||||
});
|
||||
(AppStoreConnectApiKeyQuery.getByIdAsync as jest.Mock).mockResolvedValue({
|
||||
keyP8: 'stored-key-p8',
|
||||
keyIdentifier: 'KEY789',
|
||||
issuerIdentifier: 'ISSUER012',
|
||||
});
|
||||
|
||||
const args = createBaseArgs();
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(AppStoreConnectApiKeyQuery.getByIdAsync).toHaveBeenCalledWith(
|
||||
args.graphqlClient,
|
||||
'asc-key-id'
|
||||
);
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'stored-key-p8',
|
||||
keyId: 'KEY789',
|
||||
issuerId: 'ISSUER012',
|
||||
},
|
||||
teamId: 'TEAM123',
|
||||
teamName: 'Test Team',
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('falls back to interactive cookie auth when no API key and interactive mode', async () => {
|
||||
const args = createBaseArgs({ nonInteractive: false });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
// Should call ensureAuthenticatedAsync without API key options
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when no API key is available', async () => {
|
||||
const args = createBaseArgs({ nonInteractive: true });
|
||||
|
||||
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
|
||||
});
|
||||
|
||||
it('falls back gracefully when credentials service lookup fails', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// Interactive mode: should fall back to cookie auth
|
||||
const args = createBaseArgs({ nonInteractive: false });
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('falls back gracefully when credentials service fails in non-interactive mode', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const args = createBaseArgs({ nonInteractive: true });
|
||||
|
||||
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
|
||||
});
|
||||
|
||||
it('prefers submit profile key over credentials service', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'PROFILE_KEY',
|
||||
ascApiKeyIssuerId: 'PROFILE_ISSUER',
|
||||
} as any;
|
||||
// Also set up a stored key that should NOT be used
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'stored-key-id',
|
||||
keyIdentifier: 'STORED_KEY',
|
||||
issuerIdentifier: 'STORED_ISSUER',
|
||||
});
|
||||
|
||||
const args = createBaseArgs({ profile });
|
||||
await getAppStoreAuthAsync(args);
|
||||
|
||||
// Should use profile key, not the stored one
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ascApiKey: expect.objectContaining({ keyId: 'PROFILE_KEY' }),
|
||||
})
|
||||
);
|
||||
// Should NOT have called getByIdAsync since profile key was found first
|
||||
expect(AppStoreConnectApiKeyQuery.getByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { downloadMetadataAsync } from '../download';
|
||||
import { MetadataValidationError } from '../errors';
|
||||
|
||||
jest.mock('../auth', () => ({
|
||||
getAppStoreAuthAsync: jest.fn(() => ({
|
||||
app: { id: '123' },
|
||||
auth: { context: { token: 'mock' } },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../apple/tasks', () => ({
|
||||
createAppleTasks: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../config/resolve', () => ({
|
||||
createAppleWriter: jest.fn(() => ({
|
||||
toSchema: jest.fn(() => ({ configVersion: 0 })),
|
||||
})),
|
||||
getStaticConfigFilePath: jest.fn(() => '/app/store.config.json'),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/telemetry', () => ({
|
||||
subscribeTelemetryAsync: jest.fn(() => ({
|
||||
unsubscribeTelemetry: jest.fn(),
|
||||
executionId: 'exec-123',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('fs-extra', () => ({
|
||||
pathExists: jest.fn(() => false),
|
||||
writeJSON: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
jest.mock('../../prompts', () => ({
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { bundleIdentifier: 'com.example.app' } as any,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
analytics: {} as any,
|
||||
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(downloadMetadataAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('skips overwrite prompt and auto-overwrites in non-interactive mode', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: true }));
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeJSON).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for overwrite in interactive mode when file exists', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('overwrite') })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws MetadataValidationError when user declines overwrite in interactive mode', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(downloadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prompt when file does not exist', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { uploadMetadataAsync } from '../upload';
|
||||
import { MetadataValidationError } from '../errors';
|
||||
|
||||
jest.mock('../auth', () => ({
|
||||
getAppStoreAuthAsync: jest.fn(() => ({
|
||||
app: { id: '123' },
|
||||
auth: { context: { token: 'mock' } },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../apple/tasks', () => ({
|
||||
createAppleTasks: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../config/resolve', () => ({
|
||||
createAppleReader: jest.fn(() => ({
|
||||
getVersion: jest.fn(() => ({ versionString: '1.0.0' })),
|
||||
})),
|
||||
loadConfigAsync: jest.fn(() => ({ configVersion: 0 })),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/telemetry', () => ({
|
||||
subscribeTelemetryAsync: jest.fn(() => ({
|
||||
unsubscribeTelemetry: jest.fn(),
|
||||
executionId: 'exec-123',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
jest.mock('../../prompts', () => ({
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
|
||||
typeof import('../config/resolve')
|
||||
>;
|
||||
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
|
||||
|
||||
function createArgs(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
projectDir: '/app',
|
||||
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
|
||||
exp: { slug: 'my-app', name: 'My App' } as any,
|
||||
analytics: {} as any,
|
||||
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
|
||||
nonInteractive: false,
|
||||
graphqlClient: {} as any,
|
||||
projectId: 'test-project-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(uploadMetadataAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(loadConfigAsync as jest.Mock).mockResolvedValue({ configVersion: 0 });
|
||||
});
|
||||
|
||||
it('throws MetadataValidationError directly in non-interactive mode without prompting', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValue(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
|
||||
await expect(uploadMetadataAsync(createArgs({ nonInteractive: true }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts user about validation errors in interactive mode', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValueOnce(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(true);
|
||||
// Second call (with skipValidation) succeeds
|
||||
(loadConfigAsync as jest.Mock).mockResolvedValueOnce({ configVersion: 0 });
|
||||
|
||||
await uploadMetadataAsync(createArgs({ nonInteractive: false }));
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('still want to push') })
|
||||
);
|
||||
});
|
||||
|
||||
it('re-throws validation error when user declines to continue in interactive mode', async () => {
|
||||
(loadConfigAsync as jest.Mock).mockRejectedValue(
|
||||
new MetadataValidationError('validation errors found')
|
||||
);
|
||||
(confirmAsync as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(uploadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
|
||||
MetadataValidationError
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads successfully when config is valid', async () => {
|
||||
const result = await uploadMetadataAsync(createArgs());
|
||||
|
||||
expect(result).toHaveProperty('appleLink');
|
||||
expect(result.appleLink).toContain('appstoreconnect.apple.com');
|
||||
});
|
||||
});
|
||||
@@ -2,22 +2,10 @@ import { App, Session } from '@expo/apple-utils';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { SubmitProfile } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { AppLookupParams } from '../credentials/ios/api/graphql/types/AppLookupParams';
|
||||
import {
|
||||
Options as AuthOptions,
|
||||
getRequestContext,
|
||||
} from '../credentials/ios/appstore/authenticate';
|
||||
import { AppleTeamType, AuthenticationMode } from '../credentials/ios/appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../credentials/ios/appstore/resolveCredentials';
|
||||
import { MinimalAscApiKey } from '../credentials/ios/credentials';
|
||||
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../log';
|
||||
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
|
||||
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
export type MetadataAppStoreAuthentication = {
|
||||
@@ -44,107 +32,20 @@ async function resolveAppStoreBundleIdentifierAsync(
|
||||
return await getBundleIdentifierAsync(projectDir, exp, vcsClient);
|
||||
}
|
||||
|
||||
type ResolvedAscApiKey = {
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to resolve an ASC API key from the submit profile or EAS credentials service.
|
||||
* Returns null if no key is available from these sources.
|
||||
*/
|
||||
async function tryResolveAscApiKeyAsync({
|
||||
profile,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
bundleId,
|
||||
}: {
|
||||
profile: SubmitProfile;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
bundleId: string;
|
||||
}): Promise<ResolvedAscApiKey | null> {
|
||||
// 1. Check submit profile for ASC API key fields
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
|
||||
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
|
||||
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
|
||||
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
|
||||
// Also try to get teamId from the profile if available
|
||||
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
|
||||
return {
|
||||
ascApiKey: { keyP8, keyId: ascApiKeyId, issuerId: ascApiKeyIssuerId },
|
||||
teamId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Look up stored credentials via EAS credentials service
|
||||
try {
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const appLookupParams: AppLookupParams = {
|
||||
account,
|
||||
projectName: exp.slug,
|
||||
bundleIdentifier: bundleId,
|
||||
};
|
||||
|
||||
// Import dynamically to avoid circular dependency issues
|
||||
const { getAscApiKeyForAppSubmissionsAsync } =
|
||||
await import('../credentials/ios/api/GraphqlClient');
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, appLookupParams);
|
||||
|
||||
if (ascKeyFragment) {
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(
|
||||
graphqlClient,
|
||||
ascKeyFragment.id
|
||||
);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If we can't look up credentials, that's fine — we'll fall back
|
||||
Log.warn(`Could not look up stored ASC API key: ${error.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* To start syncing ASC entities, we need access to the apple utils App instance.
|
||||
* This resolves both the authentication and that App instance.
|
||||
*
|
||||
* Resolution order for authentication:
|
||||
* 1. ASC API key from environment variables (EXPO_ASC_API_KEY_PATH, etc.)
|
||||
* 2. ASC API key from submit profile (ascApiKeyPath, etc. in eas.json)
|
||||
* 3. ASC API key from EAS credentials service
|
||||
* 4. Interactive cookie auth (only when not in non-interactive mode)
|
||||
*/
|
||||
export async function getAppStoreAuthAsync({
|
||||
projectDir,
|
||||
profile,
|
||||
exp,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<MetadataAppStoreAuthentication> {
|
||||
const bundleId = await resolveAppStoreBundleIdentifierAsync(
|
||||
projectDir,
|
||||
@@ -153,49 +54,10 @@ export async function getAppStoreAuthAsync({
|
||||
credentialsCtx.vcsClient
|
||||
);
|
||||
|
||||
// Try to resolve an ASC API key from profile or credentials service
|
||||
const resolvedKey = await tryResolveAscApiKeyAsync({
|
||||
profile,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
exp,
|
||||
bundleId,
|
||||
});
|
||||
|
||||
if (resolvedKey || hasAscEnvVars()) {
|
||||
const authOptions: AuthOptions = {
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
...(resolvedKey
|
||||
? {
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId: resolvedKey.teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
// Default to COMPANY_OR_ORGANIZATION to avoid prompting for team type
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync(authOptions);
|
||||
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
|
||||
|
||||
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
|
||||
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
|
||||
return { app, auth: authCtx.authState };
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
|
||||
' - EAS credentials service: run `eas credentials` to set up an API key'
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to interactive cookie auth
|
||||
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync();
|
||||
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
|
||||
|
||||
// TODO: improve error handling by mentioning possible configuration errors
|
||||
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
|
||||
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createAppleWriter, getStaticConfigFilePath } from './config/resolve';
|
||||
import { MetadataDownloadError, MetadataValidationError } from './errors';
|
||||
import { subscribeTelemetryAsync } from './utils/telemetry';
|
||||
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import Log from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
@@ -25,33 +24,23 @@ export async function downloadMetadataAsync({
|
||||
exp,
|
||||
analytics,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
analytics: Analytics;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<string> {
|
||||
const filePath = getStaticConfigFilePath({ projectDir, profile });
|
||||
|
||||
const fileExists = await fs.pathExists(filePath);
|
||||
if (fileExists) {
|
||||
if (nonInteractive) {
|
||||
Log.log(`Overwriting existing store config at "${path.relative(projectDir, filePath)}".`);
|
||||
} else {
|
||||
const filePathRelative = path.relative(projectDir, filePath);
|
||||
const overwrite = await confirmAsync({
|
||||
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
|
||||
}
|
||||
const filePathRelative = path.relative(projectDir, filePath);
|
||||
const overwrite = await confirmAsync({
|
||||
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
|
||||
});
|
||||
if (!overwrite) {
|
||||
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +49,6 @@ export async function downloadMetadataAsync({
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
|
||||
|
||||
@@ -9,7 +9,6 @@ import { MetadataConfig } from './config/schema';
|
||||
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
|
||||
import { subscribeTelemetryAsync } from './utils/telemetry';
|
||||
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import Log from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
@@ -24,32 +23,19 @@ export async function uploadMetadataAsync({
|
||||
exp,
|
||||
analytics,
|
||||
credentialsCtx,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profile: SubmitProfile;
|
||||
exp: ExpoConfig;
|
||||
analytics: Analytics;
|
||||
credentialsCtx: CredentialsContext;
|
||||
nonInteractive: boolean;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<{ appleLink: string }> {
|
||||
const storeConfig = await loadConfigWithValidationPromptAsync(
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive
|
||||
);
|
||||
const storeConfig = await loadConfigWithValidationPromptAsync(projectDir, profile);
|
||||
const { app, auth } = await getAppStoreAuthAsync({
|
||||
exp,
|
||||
credentialsCtx,
|
||||
projectDir,
|
||||
profile,
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
|
||||
@@ -98,18 +84,12 @@ export async function uploadMetadataAsync({
|
||||
|
||||
async function loadConfigWithValidationPromptAsync(
|
||||
projectDir: string,
|
||||
profile: SubmitProfile,
|
||||
nonInteractive: boolean
|
||||
profile: SubmitProfile
|
||||
): Promise<MetadataConfig> {
|
||||
try {
|
||||
return await loadConfigAsync({ projectDir, profile });
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataValidationError) {
|
||||
if (nonInteractive) {
|
||||
logMetadataValidationError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logMetadataValidationError(error);
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
|
||||
@@ -137,81 +137,21 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { android: { package: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
'app.json': '{ "blah": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: '' } as any,
|
||||
projectId: '',
|
||||
exp: {} as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -148,8 +148,15 @@ async function configureApplicationIdAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -159,50 +166,25 @@ async function configureApplicationIdAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
let packageName: string;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
|
||||
if (nonInteractive) {
|
||||
let suggestedAndroidApplicationId: string | undefined;
|
||||
try {
|
||||
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedAndroidApplicationId) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
packageName = suggestedAndroidApplicationId;
|
||||
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
|
||||
} else {
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
|
||||
const result = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
packageName = result.packageName;
|
||||
}
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
const { packageName } = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -115,81 +115,20 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
'app.json': '{ "blah": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: '' } as any,
|
||||
exp: {} as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -137,8 +137,15 @@ async function configureBundleIdentifierAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -148,51 +155,26 @@ async function configureBundleIdentifierAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
let bundleIdentifier: string;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
|
||||
if (nonInteractive) {
|
||||
let suggestedBundleIdentifier: string | undefined;
|
||||
try {
|
||||
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedBundleIdentifier) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
bundleIdentifier = suggestedBundleIdentifier;
|
||||
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
|
||||
} else {
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
|
||||
const result = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value =>
|
||||
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
|
||||
});
|
||||
bundleIdentifier = result.bundleIdentifier;
|
||||
}
|
||||
const { bundleIdentifier } = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
|
||||
});
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -10,8 +10,21 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
|
||||
import { AccountFragment } from '../graphql/generated';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { Actor } from '../user/User';
|
||||
import { expoCommandAsync } from '../utils/expoCli';
|
||||
|
||||
export function getUsernameForBuildMetadataAndBuildJob(user: Actor): string | undefined {
|
||||
switch (user.__typename) {
|
||||
case 'User':
|
||||
return user.username;
|
||||
case 'SSOUser':
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
// robot users don't have usernames
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a useful name describing the project config.
|
||||
* - dynamic: app.config.js
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
import { easCliVersion } from './utils/easCli';
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.EAS_CLI_SENTRY_DSN,
|
||||
enabled: !!process.env.EAS_CLI_SENTRY_DSN,
|
||||
environment: getSentryEnvironment(),
|
||||
release: easCliVersion ? `eas-cli@${easCliVersion}` : undefined,
|
||||
});
|
||||
Sentry.setTag('source', 'eas-cli');
|
||||
|
||||
function getSentryEnvironment(): string {
|
||||
if (process.env.EXPO_LOCAL) {
|
||||
return 'local';
|
||||
} else if (process.env.EXPO_STAGING) {
|
||||
return 'staging';
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
|
||||
export default Sentry;
|
||||
@@ -7,7 +7,6 @@ import semver from 'semver';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
PartnerActor,
|
||||
Robot,
|
||||
SsoUser,
|
||||
Update,
|
||||
@@ -30,7 +29,6 @@ export type FormatUpdateParameter = Pick<Update, 'id' | 'createdAt' | 'message'>
|
||||
| Pick<Robot, '__typename' | 'firstName'>
|
||||
| Pick<User, '__typename' | 'username'>
|
||||
| Pick<SsoUser, '__typename' | 'username'>
|
||||
| Pick<PartnerActor, '__typename' | 'username'>
|
||||
| null;
|
||||
};
|
||||
|
||||
@@ -196,7 +194,6 @@ export function formatUpdateTitle(update: UpdateFragment): string {
|
||||
let actorName: string;
|
||||
switch (actor?.__typename) {
|
||||
case 'User':
|
||||
case 'PartnerActor':
|
||||
case 'SSOUser': {
|
||||
actorName = actor.username;
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
|
||||
import { CurrentUserQuery, Robot, SsoUser, User } from '../graphql/generated';
|
||||
|
||||
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
|
||||
|
||||
@@ -12,7 +12,6 @@ export function getActorDisplayName(
|
||||
| Pick<Robot, '__typename' | 'firstName'>
|
||||
| Pick<User, '__typename' | 'username'>
|
||||
| Pick<SsoUser, '__typename' | 'username'>
|
||||
| Pick<PartnerActor, '__typename' | 'username'>
|
||||
| null
|
||||
): string {
|
||||
switch (actor?.__typename) {
|
||||
@@ -22,8 +21,6 @@ export function getActorDisplayName(
|
||||
return actor.firstName ? `${actor.firstName} (robot)` : 'robot';
|
||||
case 'SSOUser':
|
||||
return actor.username;
|
||||
case 'PartnerActor':
|
||||
return actor.username;
|
||||
case undefined:
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -33,7 +30,6 @@ export function getActorUsername(actor?: Actor): string | null {
|
||||
switch (actor?.__typename) {
|
||||
case 'User':
|
||||
case 'SSOUser':
|
||||
case 'PartnerActor':
|
||||
return actor.username;
|
||||
case 'Robot':
|
||||
case undefined:
|
||||
|
||||
@@ -1,86 +1,24 @@
|
||||
import assert from 'assert';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
import crypto from 'crypto';
|
||||
import http from 'http';
|
||||
import { Socket } from 'node:net';
|
||||
import querystring from 'querystring';
|
||||
|
||||
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../api';
|
||||
import fetch from '../fetch';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import Log from '../log';
|
||||
|
||||
const CLIENT_ID = 'eas-cli';
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(codeVerifier: string): string {
|
||||
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
async function exchangeCodeForSessionSecretAsync({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
}: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<string> {
|
||||
const tokenUrl = `${getExpoApiBaseUrl()}/v2/auth/token`;
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
const result: any = await response.json();
|
||||
const sessionSecret = result?.data?.session_secret;
|
||||
if (!sessionSecret) {
|
||||
throw new Error('Failed to obtain session secret from token exchange.');
|
||||
}
|
||||
return sessionSecret;
|
||||
}
|
||||
|
||||
export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Promise<string> {
|
||||
const scheme = 'http';
|
||||
const hostname = 'localhost';
|
||||
const callbackPath = '/auth/callback';
|
||||
const path = '/auth/callback';
|
||||
|
||||
const expoWebsiteUrl = getExpoWebsiteBaseUrl();
|
||||
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
const buildRedirectUri = (port: number): string =>
|
||||
`${scheme}://${hostname}:${port}${callbackPath}`;
|
||||
|
||||
const buildExpoLoginUrl = (port: number, sso: boolean): string => {
|
||||
// Note: we avoid URLSearchParams here because better-opn calls encodeURI()
|
||||
// on the URL before passing it to AppleScript, which would double-encode
|
||||
// the percent-encoded values from URLSearchParams.toString().
|
||||
const params = [
|
||||
`client_id=${CLIENT_ID}`,
|
||||
`redirect_uri=${buildRedirectUri(port)}`,
|
||||
`response_type=code`,
|
||||
`code_challenge=${codeChallenge}`,
|
||||
`code_challenge_method=S256`,
|
||||
`state=${state}`,
|
||||
`confirm_account=true`,
|
||||
].join('&');
|
||||
const params = querystring.stringify({
|
||||
confirm_account: true,
|
||||
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
|
||||
});
|
||||
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
|
||||
};
|
||||
|
||||
@@ -101,39 +39,22 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestAsync = async (): Promise<void> => {
|
||||
try {
|
||||
if (!(request.method === 'GET' && request.url?.includes('/auth/callback'))) {
|
||||
throw new Error('Unexpected login response.');
|
||||
}
|
||||
const url = new URL(request.url, `http:${request.headers.host}`);
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const sessionSecret = url.searchParams.get('session_secret');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Request missing code search parameter.');
|
||||
if (!sessionSecret) {
|
||||
throw new Error('Request missing session_secret search parameter.');
|
||||
}
|
||||
if (returnedState !== state) {
|
||||
throw new Error('State mismatch. Possible CSRF attack.');
|
||||
}
|
||||
|
||||
const address = server.address();
|
||||
assert(address !== null && typeof address === 'object');
|
||||
const redirectUri = buildRedirectUri(address.port);
|
||||
|
||||
const sessionSecret = await exchangeCodeForSessionSecretAsync({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
resolve(sessionSecret);
|
||||
redirectAndCleanup('success');
|
||||
};
|
||||
|
||||
handleRequestAsync().catch(error => {
|
||||
} catch (error) {
|
||||
redirectAndCleanup('error');
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "18.4.0",
|
||||
"version": "18.2.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -26,13 +26,13 @@
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"test": "jest",
|
||||
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
|
||||
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.4.0",
|
||||
"version": "18.2.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -21,13 +21,13 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --passWithNoTests",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.4.0",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/build-tools": "18.2.0",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
@@ -50,7 +50,6 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user