Compare commits

..
189 changed files with 6766 additions and 7393 deletions

No files matched your search

@@ -12,8 +12,6 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
+8 -43
View File
@@ -6,51 +6,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
### 🧹 Chores
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
### 🎉 New features
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
### 🐛 Bug fixes
### 🧹 Chores
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
+1 -3
View File
@@ -296,11 +296,9 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.3.0",
"version": "18.0.4",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^4.22.85",
"oclif": "^3.9.0",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.2.0",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -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.2.0",
"@expo/eas-build-job": "18.0.2",
"@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.2.0",
"@expo/steps": "18.0.2",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
@@ -53,7 +53,6 @@
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
@@ -19,7 +19,3 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -26,14 +26,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects log for invalid bundler and reports it to user', async () => {
@@ -51,11 +45,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
});
it('does not detect errors if they show up in different build phase', async () => {
@@ -73,10 +63,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects npm cache error if cache is enabled', async () => {
@@ -95,11 +82,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('does not detect npm cache error if cache is disabled', async () => {
@@ -119,10 +103,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
});
it('detects xcode line error', async () => {
@@ -145,7 +126,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
});
it('detects minimum deployment target error correctly', async () => {
@@ -163,7 +144,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -181,7 +162,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
@@ -197,7 +178,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
@@ -213,7 +194,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -259,8 +240,8 @@ note`;
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message)
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingMessage)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
@@ -282,9 +263,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -304,8 +285,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -6,8 +6,7 @@ import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string,
public metadata?: Record<string, unknown>
public message: string
) {
super(message);
}
@@ -155,9 +154,7 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
@@ -80,17 +80,27 @@ export async function resolveBuildPhaseErrorAsync(
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userFacingError.errorCode
? buildError.errorCode
: undefined;
const isUnknownUserError =
!userFacingError ||
(
[
errors.ErrorCode.UNKNOWN_ERROR,
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
] as string[]
).includes(userFacingError.errorCode);
const message =
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
const errorCode =
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
userFacingError.errorCode;
return new errors.BuildError(userFacingError.message, {
errorCode: userFacingError.errorCode,
trackingCode,
return new errors.BuildError(message, {
errorCode,
userFacingErrorCode: userFacingError.errorCode,
userFacingMessage: userFacingError.message,
docsUrl: userFacingError.docsUrl,
cause: error,
innerError: error,
buildPhase: phase,
metadata: buildError?.metadata,
});
}
@@ -24,8 +24,7 @@ export async function installDependenciesAsync({
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
break;
}
case PackageManager.PNPM: {
@@ -45,12 +44,7 @@ export async function installDependenciesAsync({
];
}
} else {
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
}
break;
}
+1 -1
View File
@@ -299,7 +299,7 @@ export class BuildContext<TJob extends Job = Job> {
// leaving message empty, website will display err.stack which already includes err.message
this.logger.error({ err }, '');
} else {
this.logger.error(`Error: ${buildError.message}`);
this.logger.error(`Error: ${buildError.userFacingMessage}`);
}
return buildError;
}
@@ -10,7 +10,6 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
@@ -54,7 +53,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
@@ -67,7 +65,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
@@ -21,7 +21,6 @@ import { createInternalEasMaestroTestFunction } from './functions/internalMaestr
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -82,8 +81,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -1,210 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -1,594 +0,0 @@
import { vol } from 'memfs';
import {
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
{
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
errorMessage: null,
duration: 10500,
retryCount: 0,
tags: [],
properties: {},
},
{
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Tap failed',
duration: 5000,
retryCount: 0,
tags: [],
properties: {},
},
])
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
it('extracts tags from JUnit properties and separates from other properties', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="tags" value="e2e, smoke"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tmp/maestro-reports-abc123/junit-report-flow-2.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>Failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'home',
status: 'passed',
duration: 10500,
errorMessage: null,
tags: [],
properties: {},
},
]);
});
it('parses a failed testcase with failure message', async () => {
vol.fromJSON({
'/junit/report.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>Element not visible</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'login',
status: 'failed',
duration: 5000,
errorMessage: 'Element not visible',
tags: [],
properties: {},
},
]);
});
it('extracts properties from testcase', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
});
it('extracts tags from "tags" property as comma-separated values', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="tags" value="smoke, critical, auth"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('returns empty tags when no "tags" property exists (older Maestro)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual([]);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('parses multiple testcases across multiple testsuites (shards)', async () => {
vol.fromJSON({
'/junit/report.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="10.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed' }),
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
])
);
});
it('parses multiple JUnit XML files in the same directory', async () => {
vol.fromJSON({
'/junit/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-2.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>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
});
it('handles missing time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('handles invalid time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="not_a_number" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
vol.fromJSON({
'/junit/report.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"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBeNull();
});
it('extracts error message from <error> element', async () => {
vol.fromJSON({
'/junit/report.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">',
' <error>Runtime exception occurred</error>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBe('Runtime exception occurred');
});
it('returns empty array when directory does not exist', async () => {
const results = await parseJUnitTestCases('/nonexistent');
expect(results).toEqual([]);
});
it('returns empty array when no XML files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([]);
});
it('skips invalid XML files gracefully', async () => {
vol.fromJSON({
'/junit/bad.xml': 'not xml at all',
'/junit/good.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
});
it('handles testcase with no properties element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({});
});
});
@@ -1,273 +0,0 @@
import { Client } from '@urql/core';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n');
const JUNIT_FAIL = [
'<?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');
const FLOW_AI = JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
});
describe(createReportMaestroTestResultsFunction, () => {
let mockMutationFn: jest.Mock;
let mockGraphqlClient: Client;
beforeEach(() => {
mockMutationFn = jest.fn();
mockGraphqlClient = {
mutation: jest.fn().mockReturnValue({
toPromise: mockMutationFn,
}),
} as unknown as Client;
});
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
...overrides?.staticContextContent,
},
});
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
junit_report_directory: '/junit',
tests_directory: '/tests',
...overrides?.callInputs,
},
});
}
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.workflowJobId).toBe('job-uuid-123');
expect(variables.input.testCaseResults).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'PASSED',
errorMessage: null,
duration: 10000,
retryCount: 0,
properties: {},
}),
]);
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="testCaseId" value="TC-001"/>',
' <property name="priority" value="high"/>',
' <property name="tags" value="e2e"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
expect(variables.input.testCaseResults[0].properties).toEqual({
testCaseId: 'TC-001',
priority: 'high',
});
});
it('reports failed flow with correct status and errorMessage', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_FAIL,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({
status: 'FAILED',
errorMessage: 'Tap failed',
duration: 5000,
})
);
});
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
// not optional), so it's always present in workflow jobs. graphqlClient is already
// initialized with the token in BuildContext constructor.
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
const step = createStep({
callInputs: { junit_report_directory: '' },
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const step = createStep({ env: {} });
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when no JUnit files found', async () => {
const step = createStep();
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('does not throw when GraphQL returns error', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
error: { message: 'Something went wrong' },
});
await createStep().executeAsync();
});
it('does not throw when mutation rejects', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockRejectedValue(new Error('Network error'));
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
},
});
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -1,177 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
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' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
});
@@ -1,97 +0,0 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -24,7 +24,6 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
@@ -82,10 +81,6 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
@@ -353,10 +348,6 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
@@ -414,9 +405,6 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
@@ -438,7 +426,7 @@ async function withCleanDeviceAsync<TResult>({
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
switch (platform) {
case 'ios': {
@@ -462,81 +450,28 @@ async function withCleanDeviceAsync<TResult>({
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(`Starting Android Emulator ${localDeviceName}...`);
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
localDeviceIdentifier = serialId;
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
@@ -567,14 +502,14 @@ async function withCleanDeviceAsync<TResult>({
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
env,
});
break;
@@ -1,264 +0,0 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
path: string;
status: 'passed' | 'failed';
errorMessage: string | null;
duration: number; // milliseconds
retryCount: number;
tags: string[];
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
tags: string[];
properties: Record<string, string>;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
// Ensure single-element arrays are always arrays
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
entries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
if (xmlFiles.length === 0) {
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
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;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
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;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
return relative;
}
@@ -1,120 +0,0 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
createWorkflowDeviceTestCaseResults(input: $input) {
id
}
}
}
`);
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
passed: 'PASSED',
failed: 'FAILED',
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'report_maestro_test_results',
name: 'Report Maestro Test Results',
__metricsId: 'eas/report_maestro_test_results',
inputProviders: [
BuildStepInput.createProvider({
id: 'junit_report_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'tests_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
logger.info('Not running in a workflow job, skipping test results report');
return;
}
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
if (!junitDirectory) {
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// 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: ${[...new Set(duplicates)].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
return;
}
const testCaseResults = flowResults.flatMap(f => {
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
if (!status) {
return [];
}
return [
{
name: f.name,
path: f.path,
status,
errorMessage: f.errorMessage,
duration: f.duration,
retryCount: f.retryCount,
tags: f.tags,
properties: f.properties,
},
];
});
const result = await ctx.graphqlClient
.mutation(CREATE_MUTATION, {
input: {
workflowJobId,
testCaseResults,
},
})
.toPromise();
if (result.error) {
logger.error({ error: result.error }, 'GraphQL error creating test case results');
return;
}
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
} catch (error) {
logger.error({ err: error }, 'Failed to create test case results');
}
},
});
}
@@ -58,6 +58,11 @@ export function createRestoreCacheFunction(): BuildFunction {
const { logger } = stepsCtx;
try {
if (stepsCtx.global.staticContext.job.platform) {
logger.error('Caches are not supported in build jobs yet.');
return;
}
const paths = z
.array(z.string())
.parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/))
@@ -9,9 +9,6 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -75,78 +72,25 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
logger.info('Creating emulator device');
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
logger.info('Starting emulator device');
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId,
});
logger.info(`${deviceName} is ready.`);
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -161,70 +105,27 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
for (let i = 0; i < count; i++) {
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let cloneSerialId = null;
try {
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
cloneSerialId = startResult.serialId;
logger.info('Starting emulator device');
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: cloneSerialId,
env,
timeoutMs,
logger,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
logger.warn(
{ err },
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (cloneSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: cloneSerialId,
deviceName: cloneIdentifier,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: cloneIdentifier,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
logger.info(`${cloneIdentifier} is ready.`);
}
}
},
@@ -1,5 +1,3 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildStepInput,
@@ -14,8 +12,6 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -105,21 +101,34 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info(
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
stepsCtx.logger.info('Reading App information...');
const appResponse = await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
relationships: {
app: {
data: {
type: 'apps',
id: appleAppIdentifier,
},
},
},
},
});
const buildUploadId = buildUploadResponse.data.id;
@@ -225,9 +234,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
@@ -240,14 +246,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
await setTimeout(2000);
continue;
}
@@ -266,50 +265,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserFacingError(
'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` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`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).'
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
@@ -324,36 +279,6 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -11,43 +11,7 @@ type ApiSchema = {
};
};
const AscErrorResponseSchema = z.object({
errors: z
.array(
z.object({
id: z.string().optional(),
status: z.string().optional(),
code: z.string().optional(),
title: z.string().optional(),
detail: z.string().optional(),
source: z.unknown().optional(),
})
)
.min(1),
});
const GetApi = {
'/v1/apps': {
path: z.object({}),
request: z.object({
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
return opts.includes('bundleId') && opts.includes('name');
}),
limit: z.number().int().min(1).max(200).optional(),
}),
response: z.object({
data: z.array(
z.object({
type: z.literal('apps'),
id: z.string(),
attributes: z.object({
bundleId: z.string(),
name: z.string(),
}),
})
),
}),
},
'/v1/apps/:id': {
path: z.object({
id: z.string(),
@@ -258,13 +222,6 @@ const PatchApi = {
},
} satisfies ApiSchema;
export type AscApiClientGetApi = {
[Path in keyof typeof GetApi]: {
request: z.input<(typeof GetApi)[Path]['request']>;
response: z.output<(typeof GetApi)[Path]['response']>;
};
};
export type AscApiClientPostApi = {
[Path in keyof typeof PostApi]: {
request: z.input<(typeof PostApi)[Path]['request']>;
@@ -279,17 +236,6 @@ export type AscApiClientPatchApi = {
};
};
export class AscApiRequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
options?: { cause?: unknown }
) {
super(message, { cause: options?.cause });
}
}
export class AscApiClient {
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
private readonly token: string;
@@ -400,31 +346,12 @@ export class AscApiClient {
if (!response.ok) {
const text = await response.text();
const parsedAscErrorResponse = await asyncResult(
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
);
if (parsedAscErrorResponse.ok) {
throw new AscApiRequestError(
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
response.status,
parsedAscErrorResponse.value,
{ cause: response }
);
}
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
cause: response,
});
}
const text = await response.text();
const parsedJson = await asyncResult((async () => JSON.parse(text))());
if (!parsedJson.ok) {
throw new Error(
`Malformed JSON response from App Store Connect (${response.status}): ${text}`
);
}
const json = parsedJson.value;
const json = await response.json();
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
@@ -1,126 +0,0 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
AscApiClient,
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
} from './AscApiClient';
export namespace AscApiUtils {
export async function getAppInfoAsync({
client,
appleAppIdentifier,
}: {
client: Pick<AscApiClient, 'getAsync'>;
appleAppIdentifier: string;
}): Promise<AscApiClientGetApi['/v1/apps/:id']['response']> {
try {
return await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
} catch (error) {
const notFoundErrors =
error instanceof AscApiRequestError && error.status === 404
? error.responseJson.errors
: [];
const isAppNotFoundError =
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
if (!isAppNotFoundError) {
throw error;
}
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
(visibleAppsSummary
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
: ''),
{
docsUrl: 'https://expo.fyi/asc-app-id',
cause: error,
}
);
}
}
export async function createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
}: {
client: Pick<AscApiClient, 'postAsync'>;
appleAppIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
try {
return await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
relationships: {
app: {
data: {
type: 'apps',
id: appleAppIdentifier,
},
},
},
},
});
} catch (error) {
const errors =
error instanceof AscApiRequestError && error.status === 409
? error.responseJson.errors
: [];
const isDuplicateVersionError =
errors.length > 0 &&
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
if (isDuplicateVersionError) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: error,
}
);
}
throw error;
}
}
}
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)';
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -1,6 +1,6 @@
import nock from 'nock';
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
import { AscApiClient } from '../AscApiClient';
// nock needs real fetch implementation
jest.unmock('node-fetch');
@@ -280,54 +280,4 @@ describe(AscApiClient, () => {
});
expect(scope.isDone()).toBeTruthy();
});
it('throws AscApiRequestError for structured ASC error payload', async () => {
const appId = '1491144534';
const responseFixture = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
title:
'The provided entity includes an attribute with a value that has already been used',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(409, responseFixture);
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toBeInstanceOf(AscApiRequestError);
});
it('throws regular Error for non-structured ASC error payload', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(503, 'Service unavailable');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.not.toBeInstanceOf(AscApiRequestError);
});
it('throws controlled error when success response body is not valid JSON', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(200, 'not-json');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
});
});
@@ -1,190 +0,0 @@
import { AscApiRequestError } from '../AscApiClient';
import { AscApiUtils } from '../AscApiUtils';
describe('AscApiUtils', () => {
describe('getAppInfoAsync', () => {
it('returns app info when lookup succeeds', async () => {
const response = {
data: {
type: 'apps',
id: '1491144534',
attributes: {
name: 'Example App',
bundleId: 'com.example.app',
},
},
} as const;
const client = {
getAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1491144534' })
).resolves.toEqual(response);
});
it('throws UserFacingError with visible apps when app id is not found', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
detail: "There is no resource of type 'apps' with id '1234567890'",
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockResolvedValueOnce({
data: [
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
],
}),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
),
})
);
});
it('rethrows original not-found error when app-list lookup fails', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const listingError = new Error('listing failed');
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockRejectedValueOnce(listingError),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toBe(notFoundError);
});
});
describe('createBuildUploadAsync', () => {
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
const duplicateError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(duplicateError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
message: expect.stringContaining('Increment Build Number'),
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
})
);
});
it('returns response when upload initialization succeeds', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).resolves.toEqual(response);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
},
{
status: '409',
code: 'SOME_OTHER_ERROR',
},
],
};
const mixedError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(mixedError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toBe(mixedError);
});
});
});
@@ -1,5 +1,4 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
@@ -18,8 +17,6 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
export namespace AndroidEmulatorUtils {
const RETRY_INTERVAL_MS = 1_000;
export const defaultSystemImagePackage = `system-images;android-30;default;${
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
}`;
@@ -355,15 +352,10 @@ export namespace AndroidEmulatorUtils {
export async function waitForReadyAsync({
serialId,
env,
timeoutMs = 3 * 60 * 1_000,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
logger?: bunyan;
}): Promise<void> {
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
await retryAsync(
async () => {
const { stdout } = await spawn(
@@ -375,48 +367,17 @@ export namespace AndroidEmulatorUtils {
if (!stdout.startsWith('1')) {
throw new Error(`Emulator (${serialId}) boot has not completed.`);
}
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
if (!hasNetworkConnection) {
throw new Error(`Emulator (${serialId}) network is not ready.`);
}
},
{
// Retry every second for 3 minutes.
retryOptions: {
retries,
retryIntervalMs: RETRY_INTERVAL_MS,
retries: 3 * 60,
retryIntervalMs: 1_000,
},
logger,
}
);
}
async function hasNetworkConnectionAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
if (networkReadyCheckCommand) {
const customNetworkCheckResult = await asyncResult(
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
);
return customNetworkCheckResult.ok;
}
const netcatResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
)
);
return netcatResult.ok;
}
export async function collectLogsAsync({
serialId,
env,
@@ -458,13 +419,18 @@ export namespace AndroidEmulatorUtils {
return { outputPath };
}
export async function stopAsync({
export async function deleteAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
await retryAsync(
@@ -477,40 +443,12 @@ export namespace AndroidEmulatorUtils {
{
retryOptions: {
retries: 3 * 60,
retryIntervalMs: RETRY_INTERVAL_MS,
retryIntervalMs: 1_000,
},
}
);
}
export async function deleteAsync({
serialId,
deviceName,
env,
}: {
serialId?: AndroidDeviceSerialId;
deviceName?: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
let resolvedDeviceName = deviceName;
if (!resolvedDeviceName && serialId) {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
| AndroidVirtualDeviceName
| undefined;
}
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
const serialIdToStop =
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
if (serialIdToStop) {
await stopAsync({ serialId: serialIdToStop, env });
}
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
}
export async function startScreenRecordingAsync({
@@ -9,7 +9,6 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -105,10 +104,7 @@ describe('AndroidEmulatorUtils', () => {
});
expect(stdout).toContain('data');
await AndroidEmulatorUtils.stopAsync({
serialId,
env: process.env,
});
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
await asyncResult(emulatorPromise);
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
@@ -135,133 +131,12 @@ describe('AndroidEmulatorUtils', () => {
expect(stdoutClone).toContain('data');
await AndroidEmulatorUtils.deleteAsync({
deviceName,
serialId,
env: process.env,
});
await asyncResult(emulatorPromiseClone);
}, 60_000);
it('should delete a running emulator by device name', async () => {
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: process.env,
});
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
await asyncResult(emulatorPromise);
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
expect(devices.map(device => device.serialId)).not.toContain(serialId);
await expect(fs.promises.access(avdPath)).rejects.toThrow();
}, 60_000);
it('should retry when first startup forces network check command failure', async () => {
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
let attemptCounter = 0;
let sawNetworkNotReadyError = false;
await retryAsync(
async attemptCount => {
attemptCounter += 1;
const shouldFailNetworkCheck = attemptCount === 0;
const envForAttempt: NodeJS.ProcessEnv = {
...process.env,
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
? 'echo "network not ready" && exit 1'
: 'echo "network ready"',
};
let serialId: AndroidDeviceSerialId | null = null;
let emulatorPromise: Promise<unknown> | null = null;
try {
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: envForAttempt,
timeoutMs: 30_000,
logger: createMockLogger({ logToConsole: true }),
});
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await emulatorPromise;
} catch (err: any) {
if (err?.message?.includes('network is not ready')) {
sawNetworkNotReadyError = true;
}
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (cleanupError) {
console.warn('Cleanup failed during retry test', cleanupError);
}
if (emulatorPromise) {
await emulatorPromise;
}
throw err;
}
},
{
logger: createMockLogger({ logToConsole: true }),
retryOptions: {
retries: 1,
retryIntervalMs: 1_000,
},
}
);
expect(sawNetworkNotReadyError).toBe(true);
expect(attemptCounter).toBe(2);
}, 360_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -1,161 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
timeoutMs: 60_000,
});
expect(mockedRetryAsync).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
retryOptions: expect.objectContaining({
retries: 59,
retryIntervalMs: 1_000,
}),
})
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
});
it('throws when network is unavailable despite completed boot', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc') {
throw new Error('network unreachable');
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await expect(
AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
})
).rejects.toThrow('network is not ready');
});
it('uses overridden network readiness command when provided', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'exit 0') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: {
...process.env,
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
},
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'exit 0'],
expect.objectContaining({
env: expect.objectContaining({
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
}),
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
expect.anything()
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[2] === 'emu' && args[3] === 'kill') {
return { stdout: '', stderr: '' } as any;
}
if (args[0] === 'devices') {
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.stopAsync({
serialId: 'emulator-5554' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
env: process.env,
});
});
});
describe(AndroidEmulatorUtils.deleteAsync, () => {
it('stops by serial id and then deletes the AVD', async () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await AndroidEmulatorUtils.deleteAsync({
serialId: 'emulator-5554' as any,
deviceName: 'eas-simulator-1' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
['delete', 'avd', '-n', 'eas-simulator-1'],
{
env: process.env,
}
);
});
});
});
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.2.0",
"version": "18.0.3",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.2.0"
"@expo/steps": "18.0.2"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.2.0",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -1,60 +1,4 @@
import { BuildError, ExpoError, UserFacingError } from '../errors';
import { BuildPhase } from '../logs';
class TestExpoError extends ExpoError {
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
super(message, details);
}
}
describe(ExpoError, () => {
it('stores shared metadata and formats external payload', () => {
const cause = new Error('root cause');
const error = new TestExpoError('canonical message', {
errorCode: 'ERR_CODE',
trackingCode: 'TRACKING_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@typescript-eslint/typescript-estree' },
cause,
});
expect(error.errorCode).toBe('ERR_CODE');
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({
packageName: '@typescript-eslint/typescript-estree',
});
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
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',
buildPhase: BuildPhase.PREBUILD,
});
});
});
import { UserFacingError } from '../errors';
describe(UserFacingError, () => {
it('supports docsUrl in options', () => {
@@ -62,7 +6,6 @@ describe(UserFacingError, () => {
docsUrl: 'https://docs.example.dev',
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBeUndefined();
});
@@ -74,16 +17,14 @@ describe(UserFacingError, () => {
expect(error.cause).toBe(cause);
});
it('supports docsUrl, metadata, and cause in options', () => {
it('supports docsUrl and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
metadata: { packageName: 'expo' },
cause,
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({ packageName: 'expo' });
expect(error.cause).toBe(cause);
});
});
+24 -47
View File
@@ -17,79 +17,52 @@ export interface ExternalBuildError {
buildPhase?: BuildPhase;
}
export type ErrorMetadata = Record<string, unknown>;
interface ExpoErrorDetails<TMetadata extends ErrorMetadata = ErrorMetadata> {
interface BuildErrorDetails {
errorCode: string;
trackingCode?: string;
userFacingMessage: string;
userFacingErrorCode: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
/**
* Metadata object for the error. Used internally for Sentry/logging/debugging.
* It is not included in the external build error payload.
*/
metadata?: TMetadata;
/**
* Underlying error that caused this error to be created. Used internally to
* propagate blame stack traces to the response.
*/
cause?: unknown;
}
export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata> extends Error {
export class BuildError extends Error {
public errorCode: string;
// Internal-only classification used for Sentry, analytics, and worker internalErrorCode.
// The public error saved on builds and job runs is always `errorCode`.
public trackingCode?: string;
public userFacingMessage: string;
public userFacingErrorCode: string;
public docsUrl?: string;
public readonly metadata?: TMetadata;
public innerError?: Error;
public buildPhase?: BuildPhase;
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, { cause: details.cause });
constructor(message: string, details: BuildErrorDetails) {
super(message);
this.errorCode = details.errorCode;
this.trackingCode = details.trackingCode;
this.userFacingErrorCode = details.userFacingErrorCode;
this.userFacingMessage = details.userFacingMessage;
this.docsUrl = details.docsUrl;
this.metadata = details.metadata;
this.innerError = details.innerError;
this.buildPhase = details.buildPhase;
}
public format(): ExternalBuildError {
return {
errorCode: this.errorCode,
message: this.message,
errorCode: this.userFacingErrorCode,
message: this.userFacingMessage,
docsUrl: this.docsUrl,
buildPhase: this.buildPhase,
};
}
}
export class BuildError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, details);
}
}
export class UserFacingError extends Error {
public docsUrl?: string;
export class UserFacingError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
public errorCode: string,
public message: string,
options?: {
docsUrl?: string;
metadata?: TMetadata;
cause?: unknown;
}
options?: { docsUrl?: string; cause?: unknown }
) {
super(message, {
errorCode,
docsUrl: options?.docsUrl,
metadata: options?.metadata,
cause: options?.cause,
});
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
}
}
@@ -110,6 +83,8 @@ export class UnknownBuildError extends BuildError {
const message = 'Unknown error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
@@ -120,6 +95,8 @@ export class UnknownCustomBuildError extends BuildError {
const message = 'Unknown custom build error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
-3
View File
@@ -16,7 +16,6 @@ export enum BuildPhase {
EAS_BUILD_INTERNAL = 'EAS_BUILD_INTERNAL',
PREBUILD = 'PREBUILD',
PREPARE_CREDENTIALS = 'PREPARE_CREDENTIALS',
CONFIGURE_ANDROID_VERSION = 'CONFIGURE_ANDROID_VERSION',
CALCULATE_EXPO_UPDATES_RUNTIME_VERSION = 'CALCULATE_EXPO_UPDATES_RUNTIME_VERSION',
CONFIGURE_EXPO_UPDATES = 'CONFIGURE_EXPO_UPDATES',
EAGER_BUNDLE = 'EAGER_BUNDLE',
@@ -81,7 +80,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'Resolve build configuration',
[BuildPhase.PREBUILD]: 'Prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'Prepare credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'Configure Android version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'Calculate expo-updates runtime version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'Configure expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'Bundle JavaScript',
@@ -145,7 +143,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'resolve-build-configuration',
[BuildPhase.PREBUILD]: 'prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'prepare-credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'configure-android-version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'calculate-expo-updates-runtime-version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'configure-expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'eager-bundle',
+497 -513
View File
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -1,10 +1,17 @@
#!/usr/bin/env node
(async () => {
const oclif = require('@oclif/core');
const oclif = require('@oclif/core');
const path = require('path');
// Keep dev entrypoint failures verbose for local debugging.
oclif.settings.debug = true;
const project = path.join(__dirname, '..', 'tsconfig.json');
await oclif.execute({ development: true, dir: __dirname });
})();
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
require('@oclif/core')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
+9 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.3.0",
"version": "18.0.4",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.2.0",
"@expo/eas-json": "18.2.0",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-json": "18.0.2",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
@@ -63,10 +63,10 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.2.0",
"@expo/steps": "18.0.2",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
@@ -203,6 +203,9 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
@@ -99,7 +99,11 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -122,7 +126,11 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=ios'], ctx);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=ios'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -142,7 +150,11 @@ describe(BuildVersionGetView, () => {
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -163,7 +175,11 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
@@ -202,7 +218,11 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await expect(cmd.run()).rejects.toThrowError(
`This project is not configured for using remote version source. Add ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
@@ -133,23 +133,11 @@ export function mockTestCommand<T extends Command>(
argv: string[],
ctx: any
): T {
const config = getMockOclifConfig();
const cmd = new commandConstructor(argv, config);
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export function getMockOclifConfig(
options: Partial<ConstructorParameters<typeof Config>[0]> = {}
): Config {
const config = new Config({ root: __dirname, ...options });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import { ExitError } from '@oclif/core/lib/errors';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';
@@ -8,8 +8,6 @@ import { validatePNGsForManagedProjectAsync } from '../validate';
const fixturesPath = path.join(__dirname, 'fixtures');
const { ExitError } = Errors;
describe(validatePNGsForManagedProjectAsync, () => {
it('does not validate PNGs for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
@@ -42,9 +42,9 @@ beforeEach(() => {
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = () => {
const createTestEasCommand = (): typeof EasCommand => {
class TestEasCommand extends EasCommand {
override async runAsync(): Promise<void> {}
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
@@ -109,7 +109,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const error = new Error('Unexpected, internal error message');
runAsyncMock.mockImplementation(() => {
throw error;
@@ -126,7 +126,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
@@ -144,7 +144,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
@@ -176,7 +176,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
@@ -212,7 +212,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
throw new Error('Error message');
});
@@ -226,7 +226,7 @@ describe(EasCommand.name, () => {
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 runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
@@ -241,7 +241,7 @@ describe(EasCommand.name, () => {
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 runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
@@ -1,45 +0,0 @@
import { resolveNonInteractiveAndJsonFlags } from '../flags';
describe(resolveNonInteractiveAndJsonFlags, () => {
test('returns both false when no flags are set', () => {
expect(resolveNonInteractiveAndJsonFlags({})).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json implies --non-interactive', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('--non-interactive alone does not enable json', () => {
expect(resolveNonInteractiveAndJsonFlags({ 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
test('both flags explicitly set', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true, 'non-interactive': true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('both flags explicitly false', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': false })).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json false with --non-interactive true', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
});
@@ -37,7 +37,6 @@ describe(getProjectIdAsync, () => {
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
+11 -29
View File
@@ -2,23 +2,14 @@ import { Flags } from '@oclif/core';
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description:
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export function resolveNonInteractiveAndJsonFlags(flags: {
json?: boolean;
'non-interactive'?: boolean;
}): { json: boolean; nonInteractive: boolean } {
const json = flags.json ?? false;
const nonInteractive = flags['non-interactive'] || json;
return { json, nonInteractive };
}
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
};
@@ -37,28 +28,28 @@ export const EASMultiEnvironmentFlag = {
};
export const EASVariableFormatFlag = {
format: Flags.option({
format: Flags.enum({
description: 'Output format',
options: ['long', 'short'] as const,
options: ['long', 'short'],
default: 'short',
})(),
}),
};
export const EASVariableVisibilityFlag = {
visibility: Flags.option({
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
description: 'Visibility of the variable',
options: ['plaintext', 'sensitive', 'secret'] as const,
})(),
options: ['plaintext', 'sensitive', 'secret'],
}),
};
export type EASEnvironmentVariableScopeFlagValue = 'project' | 'account';
export const EASEnvironmentVariableScopeFlag = {
scope: Flags.option({
scope: Flags.enum<EASEnvironmentVariableScopeFlagValue>({
description: 'Scope for the variable',
options: ['project', 'account'] as const,
options: ['project', 'account'],
default: 'project',
})(),
}),
};
export const EASNonInteractiveFlag = {
@@ -74,15 +65,6 @@ export const EasJsonOnlyFlag = {
};
export const EasUpdateEnvironmentFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development".',
required: false,
default: undefined,
}),
};
export const EasUpdateEnvironmentRequiredFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development". Required for projects using Expo SDK 55 or greater.',
@@ -1,18 +1,18 @@
import { Flags, Interfaces } from '@oclif/core';
import { Flags } from '@oclif/core';
import { OptionFlag } from '@oclif/core/lib/interfaces';
import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags } from './flags';
import { EasNonInteractiveAndJsonFlags } from './flags';
export const getPaginatedQueryOptions = (
flags: Partial<
Record<keyof typeof EasPaginatedQueryFlags | keyof typeof EasNonInteractiveAndJsonFlags, any>
>
): PaginatedQueryOptions => {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
...('limit' in flags && { limit: flags.limit }),
offset: flags.offset ?? 0,
nonInteractive,
json,
nonInteractive: flags['non-interactive'] ?? false,
json: flags.json ?? false,
};
};
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
}): Interfaces.OptionFlag<number | undefined> =>
description?: string;
}): OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
@@ -1,63 +0,0 @@
import { parsedYamlFromWorkflowContents } from '../parse';
describe('parsedYamlFromWorkflowContents', () => {
it('parses YAML workflow contents', () => {
const yamlConfig = `
jobs:
test:
type: custom
`;
expect(parsedYamlFromWorkflowContents({ yamlConfig })).toEqual({
jobs: {
test: {
type: 'custom',
},
},
});
});
it('supports shared env merge keys across multiple jobs', () => {
const yamlConfig = `
shared_env: &shared_env
EXPO_TOKEN: token
CI: "1"
jobs:
android:
type: custom
env:
<<: *shared_env
PLATFORM: android
ios:
type: custom
env:
<<: *shared_env
PLATFORM: ios
`;
expect(parsedYamlFromWorkflowContents({ yamlConfig })).toEqual({
shared_env: {
EXPO_TOKEN: 'token',
CI: '1',
},
jobs: {
android: {
type: 'custom',
env: {
EXPO_TOKEN: 'token',
CI: '1',
PLATFORM: 'android',
},
},
ios: {
type: 'custom',
env: {
EXPO_TOKEN: 'token',
CI: '1',
PLATFORM: 'ios',
},
},
},
});
});
});
@@ -1,10 +1,10 @@
import * as YAML from 'yaml';
import { z } from 'zod';
import { DefaultEnvironment } from '../../build/utils/environment';
import { booleanLike, stringLike } from '../../credentials/ios/types';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { parsedYamlFromWorkflowContents } from './parse';
const inputExtraProperties = {
description: stringLike.optional().describe('Description of the input'),
@@ -83,7 +83,7 @@ export function parseWorkflowInputsFromYaml(
yamlConfig: string
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
try {
const parsed = parsedYamlFromWorkflowContents({ yamlConfig });
const parsed = YAML.parse(yamlConfig);
return z
.record(z.string(), WorkflowDispatchInputZ)
.default({})
@@ -1,9 +0,0 @@
import * as YAML from 'yaml';
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
return YAML.parse(workflowFileContents.yamlConfig, {
// Keep in sync with backend parser options.
merge: true,
maxAliasCount: 50,
});
}
@@ -11,7 +11,6 @@ import Log from '../../log';
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
import { WorkflowFile } from '../../utils/workflowFile';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import { parsedYamlFromWorkflowContents } from './parse';
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
@@ -163,6 +162,11 @@ function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): vo
}
}
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
return parsedYaml;
}
export function workflowContentsFromParsedYaml(parsedYaml: any): string {
return YAML.stringify(parsedYaml);
}
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -202,12 +201,13 @@ function displayUsage(data: UsageDisplayData, usageData: AccountFullUsageData):
export default class AccountUsage extends EasCommand {
static override description = 'view account usage and billing for the current cycle';
static override args = {
ACCOUNT_NAME: Args.string({
static override args = [
{
name: 'ACCOUNT_NAME',
description:
'Account name to view usage for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
}),
};
},
];
static override flags = {
...EasJsonOnlyFlag,
@@ -19,14 +19,10 @@ export default class AccountView extends EasCommand {
maybeLoggedIn: { actor, authenticationInfo },
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
const displayName = getActorDisplayName(actor);
const loggedInAs = authenticationInfo.accessToken
? `${displayName} (authenticated using EXPO_TOKEN)`
: displayName;
? `${getActorDisplayName(actor)} (authenticated using EXPO_TOKEN)`
: getActorDisplayName(actor);
Log.log(chalk.green(loggedInAs));
if ('email' in actor) {
Log.log(actor.email);
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more
+1 -6
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { getAnalyticsEnabledAsync, setAnalyticsEnabledAsync } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
@@ -6,11 +5,7 @@ import Log from '../log';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static override args = {
STATUS: Args.string({
options: ['on', 'off'] as const,
}),
};
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
+7 -14
View File
@@ -1,13 +1,9 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
@@ -16,12 +12,13 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BranchCreate extends EasCommand {
static override description = 'create a branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to create',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -36,9 +33,8 @@ export default class BranchCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name },
flags,
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(BranchCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -65,9 +61,6 @@ export default class BranchCreate extends EasCommand {
initial: (await getDefaultBranchNameAsync(vcsClient)) ?? undefined,
validate: value => (value ? true : validationMessage),
}));
if (!name) {
throw new Error(validationMessage);
}
}
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, { appId: projectId, name });
+7 -10
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
@@ -6,10 +5,7 @@ import { scheduleBranchDeletionAsync } from '../../branch/delete';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
@@ -58,12 +54,13 @@ export default class BranchDelete extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to delete',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -74,7 +71,7 @@ export default class BranchDelete extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
+2 -6
View File
@@ -1,9 +1,6 @@
import { listAndRenderBranchesOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
@@ -22,12 +19,11 @@ export default class BranchList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchList, {
nonInteractive,
nonInteractive: flags['non-interactive'],
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
@@ -4,10 +4,7 @@ import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
EditUpdateBranchInput,
@@ -72,9 +69,8 @@ export default class BranchRename extends EasCommand {
async runAsync(): Promise<void> {
let {
flags: { from: currentName, to: newName, ...rawFlags },
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
} = await this.parse(BranchRename);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
+7 -10
View File
@@ -1,10 +1,6 @@
import { Args } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -16,12 +12,13 @@ import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view a branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to view',
}),
};
},
];
static override flags = {
...EasPaginatedQueryFlags,
@@ -39,7 +36,7 @@ export default class BranchView extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchView);
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { 'non-interactive': nonInteractive } = flags;
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -93,17 +93,15 @@ export async function selectBuildToCancelAsync(
export default class BuildCancel extends EasCommand {
static override description = 'cancel a build';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.option({
platform: Flags.enum({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
})(),
}),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -142,7 +140,7 @@ export default class BuildCancel extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg ?? null;
let buildId: string | null = buildIdFromArg;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
@@ -19,11 +19,11 @@ export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
};
static override contextDefinition = {
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -79,16 +79,14 @@ export async function selectBuildToDeleteAsync(
export default class BuildDelete extends EasCommand {
static override description = 'delete a build';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.option({
platform: Flags.enum({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
})(),
}),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -123,7 +121,7 @@ export default class BuildDelete extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg ?? null;
let buildId: string | null = buildIdFromArg;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
+3 -3
View File
@@ -34,10 +34,10 @@ export default class BuildDev extends EasCommand {
'run dev client simulator/emulator build with matching fingerprint or create a new one';
static override flags = {
platform: Flags.option({
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
options: [Platform.IOS, Platform.ANDROID],
}),
profile: Flags.string({
char: 'e',
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
@@ -5,10 +5,7 @@ import { pathExists } from 'fs-extra';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
@@ -26,10 +23,10 @@ export default class Download extends EasCommand {
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.option({
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
options: [Platform.IOS, Platform.ANDROID],
}),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
@@ -45,13 +42,13 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
json: jsonFlag,
platform,
fingerprint,
'dev-client': developmentClient,
...rawFlags
'non-interactive': nonInteractive,
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
loggedIn: { graphqlClient },
+13 -13
View File
@@ -10,10 +10,7 @@ import path from 'path';
import { LocalBuildMode } from '../../build/local';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
@@ -48,10 +45,10 @@ export default class Build extends EasCommand {
static override description = 'start a build';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
'skip-credentials-check': Flags.boolean({
default: false,
hidden: true,
@@ -99,7 +96,7 @@ export default class Build extends EasCommand {
description:
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
}),
'resource-class': Flags.option({
'resource-class': Flags.enum({
options: Object.values(ResourceClass),
hidden: true,
deprecated: {
@@ -108,15 +105,15 @@ export default class Build extends EasCommand {
),
},
description: 'The instance type that will be used to run this build [experimental]',
})(),
}),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
'build-logger-level': Flags.option({
'build-logger-level': Flags.enum({
description: 'The level of logs to output during the build process. Defaults to "info".',
options: Object.values(LoggerLevel),
})(),
}),
'freeze-credentials': Flags.boolean({
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
@@ -187,13 +184,16 @@ export default class Build extends EasCommand {
private sanitizeFlags(
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const nonInteractive = flags['non-interactive'];
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
if (!flags.platform && nonInteractive) {
Errors.error('--platform is required when building in non-interactive mode', { exit: 1 });
}
if (flags.json && !nonInteractive) {
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
const autoSubmit = flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined;
if (flags['what-to-test'] && !autoSubmit) {
Errors.error(
@@ -242,7 +242,7 @@ export default class Build extends EasCommand {
: {},
wait: flags['wait'],
clearCache: flags['clear-cache'],
json,
json: flags['json'],
autoSubmit,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
resourceClass: flags['resource-class'],
@@ -28,23 +28,23 @@ export default class BuildInspect extends EasCommand {
'inspect the state of the project at specific build stages, useful for troubleshooting';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: [RequestedPlatform.Android, RequestedPlatform.Ios] as const,
options: [RequestedPlatform.Android, RequestedPlatform.Ios],
required: true,
})(),
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
stage: Flags.option({
stage: Flags.enum({
char: 's',
description: STAGE_DESCRIPTION,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD] as const,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD],
required: true,
})(),
}),
output: Flags.string({
char: 'o',
description: 'Output directory.',
@@ -18,11 +18,11 @@ export default class BuildInternal extends EasCommand {
static override hidden = true;
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
options: ['android', 'ios'],
required: true,
})(),
}),
profile: Flags.string({
char: 'e',
description:
+9 -11
View File
@@ -3,10 +3,7 @@ import { Flags } from '@oclif/core';
import { BUILDS_LIMIT, listAndRenderBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -23,18 +20,18 @@ export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
options: Object.values(RequestedPlatform),
char: 'p',
})(),
status: Flags.option({
}),
status: Flags.enum({
options: Object.values(BuildStatus),
description: 'Filter only builds with the specified status',
})(),
distribution: Flags.option({
}),
distribution: Flags.enum({
options: Object.values(BuildDistributionType),
description: 'Filter only builds with the specified distribution type',
})(),
}),
channel: Flags.string(),
'app-version': Flags.string({
aliases: ['appVersion'],
@@ -86,11 +83,12 @@ export default class BuildList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
json: jsonFlag,
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
'non-interactive': nonInteractive,
} = flags;
if (buildDistribution === BuildDistributionType.SIMULATOR) {
Log.warn(
@@ -13,10 +13,7 @@ import { listAndSelectBuildOnAppAsync } from '../../build/queries';
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags } from '../../commandUtils/pagination';
import { CredentialsContext } from '../../credentials/context';
import {
@@ -69,10 +66,10 @@ export default class BuildResign extends EasCommand {
static override description = 're-sign a build archive';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
'target-profile': Flags.string({
char: 'e',
description:
@@ -245,7 +242,7 @@ export default class BuildResign extends EasCommand {
}
sanitizeFlags(flags: RawBuildResignFlags): BuildResignFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const nonInteractive = flags['non-interactive'];
if (nonInteractive && !flags.id) {
throw new Error(
`${chalk.bold('--id')} is required when running with ${chalk.bold(
@@ -254,7 +251,7 @@ export default class BuildResign extends EasCommand {
);
}
return {
json,
json: flags.json,
nonInteractive,
offset: flags.offset,
limit: flags.limit,
+3 -3
View File
@@ -62,10 +62,10 @@ export default class Run extends EasCommand {
description: 'ID of the simulator/emulator build to run',
exclusive: ['latest, path, url'],
}),
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -5,10 +5,7 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../../../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
@@ -25,10 +22,10 @@ export default class BuildVersionGetView extends EasCommand {
public static override description = 'get the latest version from EAS servers';
public static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -47,8 +44,7 @@ export default class BuildVersionGetView extends EasCommand {
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionGetView);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
if (flags.json) {
enableJsonOutput();
}
const {
@@ -61,13 +57,13 @@ export default class BuildVersionGetView extends EasCommand {
withServerSideEnvironment: null,
});
if (!flags.platform && nonInteractive) {
if (!flags.platform && flags['non-interactive']) {
throw new Error('"--platform" flag is required in non-interactive mode.');
}
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, {
nonInteractive,
nonInteractive: flags['non-interactive'],
});
const platforms = toPlatforms(requestedPlatform);
@@ -98,7 +94,7 @@ export default class BuildVersionGetView extends EasCommand {
buildProfile: profile,
platform,
vcsClient,
nonInteractive,
nonInteractive: flags['non-interactive'],
env,
});
const remoteVersions = await AppVersionQuery.latestVersionAsync(
@@ -111,7 +107,7 @@ export default class BuildVersionGetView extends EasCommand {
results[platform] = remoteVersions?.buildVersion;
}
}
if (json) {
if (flags.json) {
const jsonResults: { versionCode?: string; buildNumber?: string } = {};
if (results.android) {
jsonResults.versionCode = results.android;
@@ -26,10 +26,10 @@ export default class BuildVersionSetView extends EasCommand {
static override description = 'update version of an app';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -45,10 +45,10 @@ export default class BuildVersionSyncView extends EasCommand {
'update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
+1 -4
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
@@ -12,9 +11,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
static override description = 'view a build for your project';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EasJsonOnlyFlag,
@@ -1,12 +1,8 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createAndLinkChannelAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { enableJsonOutput } from '../../utils/json';
@@ -14,12 +10,13 @@ import { enableJsonOutput } from '../../utils/json';
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to create',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -33,9 +30,8 @@ export default class ChannelCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags,
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -57,9 +53,6 @@ export default class ChannelCreate extends EasCommand {
message: 'Provide a channel name:',
validate: value => (value ? true : validationMessage),
}));
if (!channelName) {
throw new Error(validationMessage);
}
}
await createAndLinkChannelAsync(graphqlClient, {
@@ -1,11 +1,7 @@
import { Args } from '@oclif/core';
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
@@ -15,12 +11,13 @@ import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJ
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to delete',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -33,9 +30,8 @@ export default class ChannelDelete extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { name: nameArg },
flags,
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+8 -11
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -12,10 +12,7 @@ import {
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
@@ -61,12 +58,13 @@ export async function updateChannelBranchMappingAsync(
export default class ChannelEdit extends EasCommand {
static override description = 'point a channel at a new branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -83,9 +81,8 @@ export default class ChannelEdit extends EasCommand {
async runAsync(): Promise<void> {
const {
args,
flags: { branch: branchFlag, ...rawFlags },
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelEdit);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,9 +1,6 @@
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -28,7 +25,7 @@ export default class ChannelList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,10 +6,7 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
PauseUpdateChannelMutation,
@@ -53,12 +50,13 @@ export async function pauseUpdateChannelAsync(
export default class ChannelPause extends EasCommand {
static override description = 'pause a channel to stop it from sending updates';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -73,8 +71,10 @@ export default class ChannelPause extends EasCommand {
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelPause);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelPause);
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,10 +6,7 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
ResumeUpdateChannelMutation,
@@ -53,12 +50,13 @@ export async function resumeUpdateChannelAsync(
export default class ChannelResume extends EasCommand {
static override description = 'resume a channel to start sending updates';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -73,8 +71,10 @@ export default class ChannelResume extends EasCommand {
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelResume);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelResume);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,10 +1,7 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
@@ -52,14 +49,15 @@ type ChannelRolloutArgsAndFlags = {
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = {
channel: Args.string({
static override args = [
{
name: 'channel',
description: 'channel on which the rollout should be done',
}),
};
},
];
static override flags = {
action: Flags.option({
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
@@ -99,17 +97,17 @@ export default class ChannelRollout extends EasCommand {
],
},
],
})(),
}),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
outcome: Flags.option({
outcome: Flags.enum({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
})(),
}),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
@@ -185,7 +183,8 @@ export default class ChannelRollout extends EasCommand {
runtimeVersion: rawFlags['runtime-version'],
privateKeyPath: rawFlags['private-key-path'] ?? null,
action: action ? this.getAction(action) : undefined,
...resolveNonInteractiveAndJsonFlags(rawFlags),
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
};
}
}
+7 -10
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import assert from 'assert';
import {
@@ -6,22 +5,20 @@ import {
selectChannelOnAppAsync,
} from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to view',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -39,7 +36,7 @@ export default class ChannelView extends EasCommand {
flags,
} = await this.parse(ChannelView);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
projectId,
loggedIn: { graphqlClient },
+10 -10
View File
@@ -6,10 +6,7 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
@@ -20,7 +17,7 @@ export default class Config extends EasCommand {
static override description = 'display project configuration (app.json + eas.json)';
static override flags = {
platform: Flags.option({ char: 'p', options: [Platform.ANDROID, Platform.IOS] as const })(),
platform: Flags.enum<Platform>({ char: 'p', options: [Platform.ANDROID, Platform.IOS] }),
profile: Flags.string({
char: 'e',
description:
@@ -42,11 +39,14 @@ export default class Config extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
if (flags.json) {
enableJsonOutput();
}
const { platform: maybePlatform, profile: maybeProfile } = flags;
const {
platform: maybePlatform,
profile: maybeProfile,
'non-interactive': nonInteractive,
} = flags;
const { getDynamicPublicProjectConfigAsync, projectDir, getDynamicLoggedInAsync } =
await this.getContextAsync(Config, {
nonInteractive,
@@ -80,7 +80,7 @@ export default class Config extends EasCommand {
const profile = await EasJsonUtils.getBuildProfileAsync(accessor, platform, profileName);
if (flags['eas-json-only']) {
if (json) {
if (flags.json) {
printJsonOnlyOutput({ buildProfile: profile });
} else {
const appPlatform = toAppPlatform(platform);
@@ -99,7 +99,7 @@ export default class Config extends EasCommand {
opts: { env: profile.env },
});
if (json) {
if (flags.json) {
printJsonOnlyOutput({ buildProfile: profile, appConfig });
} else {
Log.addNewLineIfNone();
@@ -10,10 +10,10 @@ export default class InitializeBuildCredentials extends EasCommand {
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: [Platform.ANDROID, Platform.IOS] as const,
})(),
options: [Platform.ANDROID, Platform.IOS],
}),
profile: Flags.string({
char: 'e',
description: 'The name of the build profile in eas.json.',
@@ -7,7 +7,7 @@ export default class Credentials extends EasCommand {
static override description = 'manage credentials';
static override flags = {
platform: Flags.option({ char: 'p', options: ['android', 'ios'] as const })(),
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
};
static override contextDefinition = {
@@ -1,12 +1,8 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
@@ -26,9 +22,7 @@ export default class WorkerAliasDelete extends EasCommand {
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = {
ALIAS_NAME: Args.string({}),
};
static override args = [{ name: 'ALIAS_NAME' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -111,10 +105,9 @@ export default class WorkerAliasDelete extends EasCommand {
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive,
json,
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
@@ -3,10 +3,7 @@ import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
@@ -166,10 +163,9 @@ export default class WorkerAlias extends EasCommand {
}
private sanitizeFlags(flags: RawDeployAliasFlags): DeployAliasFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive,
json,
nonInteractive: flags['non-interactive'],
json: flags['json'],
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim().toLowerCase(),
isProduction: flags.prod,
+4 -11
View File
@@ -1,11 +1,7 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { toggleConfirmAsync } from '../../prompts';
@@ -27,9 +23,7 @@ export default class WorkerDelete extends EasCommand {
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = {
DEPLOYMENT_ID: Args.string({}),
};
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -110,10 +104,9 @@ export default class WorkerDelete extends EasCommand {
}
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive,
json,
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
+4 -19
View File
@@ -7,11 +7,7 @@ import * as path from 'node:path';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
@@ -52,7 +48,6 @@ interface DeployFlags {
deploymentIdentifier?: string;
exportDir: string;
dryRun: boolean;
sourceMaps: boolean;
}
interface RawDeployFlags {
@@ -64,7 +59,6 @@ interface RawDeployFlags {
id?: string;
'export-dir': string;
'dry-run': boolean;
'source-maps': boolean;
}
interface UploadAssetBatchInstruction {
@@ -108,11 +102,6 @@ export default class WorkerDeploy extends EasCommand {
description: 'Outputs a tarball of the new deployment instead of uploading it.',
default: false,
}),
'source-maps': Flags.boolean({
description: 'Include source maps in the deployment.',
default: true,
allowNo: true,
}),
...EASEnvironmentFlag,
...EasNonInteractiveAndJsonFlags,
};
@@ -153,9 +142,7 @@ export default class WorkerDeploy extends EasCommand {
yield ['assets.json', JSON.stringify(params.assetMap)];
yield ['manifest.json', JSON.stringify(params.manifest)];
if (projectDist.type === 'server' && projectDist.serverPath) {
const workerFiles = WorkerAssets.listWorkerFilesAsync(projectDist.serverPath, {
skipSourceMaps: !flags.sourceMaps,
});
const workerFiles = WorkerAssets.listWorkerFilesAsync(projectDist.serverPath);
for await (const workerFile of workerFiles) {
yield [`server/${workerFile.normalizedPath}`, workerFile.data];
}
@@ -452,17 +439,15 @@ export default class WorkerDeploy extends EasCommand {
}
private sanitizeFlags(flags: RawDeployFlags): DeployFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive,
json,
nonInteractive: flags['non-interactive'],
json: flags['json'],
isProduction: !!flags.prod,
aliasName: flags.alias?.trim().toLowerCase(),
deploymentIdentifier: flags.id?.trim(),
exportDir: flags['export-dir'],
environment: flags['environment'],
dryRun: flags['dry-run'],
sourceMaps: flags['source-maps'],
};
}
}
+1 -4
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
@@ -9,9 +8,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
export default class DeviceView extends EasCommand {
static override description = 'view a device for your project';
static override args = {
UDID: Args.string({}),
};
static override args = [{ name: 'UDID' }];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
@@ -1,4 +1,6 @@
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -26,7 +28,7 @@ jest.mock('../../../utils/prompts');
describe(EnvCreate, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const variableId = 'testId';
const testAccountId = 'test-account-id';
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -20,7 +21,7 @@ describe(EnvDelete, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -16,7 +17,7 @@ jest.mock('../../../log');
describe(EnvExec, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockEnvironmentVariables = [
{
+3 -2
View File
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -20,7 +21,7 @@ jest.mock('../../../utils/prompts');
describe(EnvGet, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockVariables: EnvironmentVariableFragment[] = [
{
id: 'var1',
@@ -1,4 +1,6 @@
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -46,7 +48,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
describe(EnvList, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
beforeEach(() => {
jest.clearAllMocks();
@@ -1,7 +1,7 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import path from 'path';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -25,7 +25,7 @@ jest.mock('../../../log');
describe(EnvPull, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testTargetPath = '.env.local';
@@ -1,6 +1,6 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -23,7 +23,7 @@ jest.mock('../../../prompts');
describe(EnvPush, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testEnvPath = '.env.test';
@@ -1,7 +1,8 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -32,7 +33,7 @@ describe(EnvUpdate, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more