mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
140a763800 | ||
|
|
52bd5ec5b1 | ||
|
|
2bdc51e4bb | ||
|
|
f7411f44d7 | ||
|
|
2487b30394 | ||
|
|
8fb84e86bb | ||
|
|
d660e3602d | ||
|
|
5b9d47c02b | ||
|
|
61b601de88 | ||
|
|
c5867c8c07 | ||
|
|
f2d5c210a7 | ||
|
|
6e70f7ee4b | ||
|
|
1d36f4f636 | ||
|
|
f8deba78a5 | ||
|
|
df8ebc8f84 | ||
|
|
62ab329f39 | ||
|
|
198b796804 | ||
|
|
110055d943 | ||
|
|
06e2ba5bdb | ||
|
|
15690e7f6b | ||
|
|
134e74f50b | ||
|
|
4b3dcda99d |
No files matched your search
+19
-2
@@ -6,14 +6,31 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 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
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [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))
|
||||
|
||||
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.0.4",
|
||||
"version": "18.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.0.2",
|
||||
"version": "18.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -53,6 +53,7 @@
|
||||
"@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,3 +19,7 @@ export enum LoggerLevel {
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
export enum PipeMode {
|
||||
COMBINED = 'combined',
|
||||
}
|
||||
@@ -28,6 +28,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.type).toBe(errors.ExpoErrorType.SYSTEM);
|
||||
});
|
||||
|
||||
it('detects log for invalid bundler and reports it to user', async () => {
|
||||
@@ -46,6 +47,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.type).toBe(errors.ExpoErrorType.USER);
|
||||
});
|
||||
|
||||
it('does not detect errors if they show up in different build phase', async () => {
|
||||
|
||||
@@ -94,8 +94,10 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
const errorCode =
|
||||
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
|
||||
userFacingError.errorCode;
|
||||
const type = isUnknownUserError ? errors.ExpoErrorType.SYSTEM : errors.ExpoErrorType.USER;
|
||||
|
||||
return new errors.BuildError(message, {
|
||||
type,
|
||||
errorCode,
|
||||
userFacingErrorCode: userFacingError.errorCode,
|
||||
userFacingMessage: userFacingError.message,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
|
||||
@@ -53,6 +54,7 @@ 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;
|
||||
@@ -65,6 +67,7 @@ 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,6 +21,7 @@ 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 {
|
||||
@@ -81,6 +82,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isClosedVersionTrainError } from '../uploadToAsc';
|
||||
|
||||
describe(isClosedVersionTrainError, () => {
|
||||
it('returns true when all errors are closed-version-train codes', () => {
|
||||
expect(
|
||||
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { 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);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -81,6 +82,10 @@ 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.
|
||||
@@ -348,6 +353,10 @@ 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.');
|
||||
@@ -405,6 +414,9 @@ 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,
|
||||
@@ -426,7 +438,7 @@ async function withCleanDeviceAsync<TResult>({
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
@@ -450,28 +462,81 @@ async function withCleanDeviceAsync<TResult>({
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
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;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(
|
||||
{ err: cleanupErr },
|
||||
`Failed to clean up Android Emulator ${localDeviceName}.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!localDeviceIdentifier) {
|
||||
throw new Error('Device did not return an identifier after startup.');
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
@@ -502,14 +567,14 @@ async function withCleanDeviceAsync<TResult>({
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,9 @@ 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',
|
||||
@@ -73,24 +76,77 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
);
|
||||
|
||||
logger.info('Creating emulator device');
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
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;
|
||||
let attemptSerialId = null;
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId,
|
||||
});
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
try {
|
||||
logger.info(`Creating emulator device (attempt ${attempt}/${maxAttempts}).`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device (attempt ${attempt}/${maxAttempts}).`);
|
||||
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}.`);
|
||||
}
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -105,27 +161,71 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
|
||||
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
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 cloneSerialId = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning ${deviceName} to ${cloneIdentifier} (attempt ${attempt}/${maxAttempts}).`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting emulator device (attempt ${attempt}/${maxAttempts}).`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
});
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: cloneSerialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -12,6 +13,7 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
|
||||
export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -102,33 +104,17 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
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 });
|
||||
stepsCtx.logger.info(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
|
||||
);
|
||||
|
||||
stepsCtx.logger.info('Creating Build Upload...');
|
||||
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 buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
});
|
||||
|
||||
const buildUploadId = buildUploadResponse.data.id;
|
||||
@@ -234,6 +220,9 @@ 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: {
|
||||
@@ -246,7 +235,14 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
);
|
||||
|
||||
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
|
||||
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
|
||||
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;
|
||||
}
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
@@ -265,6 +261,13 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isClosedVersionTrainError(errors)) {
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
|
||||
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} version train is closed. ` +
|
||||
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version and submit again.'
|
||||
);
|
||||
}
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
@@ -279,6 +282,12 @@ 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'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadChunksAsync({
|
||||
uploadOperations,
|
||||
ipaPath,
|
||||
|
||||
@@ -11,7 +11,43 @@ 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(),
|
||||
@@ -222,6 +258,13 @@ 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']>;
|
||||
@@ -236,6 +279,17 @@ 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;
|
||||
@@ -346,12 +400,31 @@ 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 json = await response.json();
|
||||
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;
|
||||
|
||||
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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}`
|
||||
: ''),
|
||||
{
|
||||
cause: error,
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
{
|
||||
cause: error,
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
}
|
||||
);
|
||||
}
|
||||
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 } from '../AscApiClient';
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
|
||||
// nock needs real fetch implementation
|
||||
jest.unmock('node-fetch');
|
||||
@@ -280,4 +280,54 @@ 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
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,4 +1,5 @@
|
||||
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';
|
||||
@@ -17,6 +18,8 @@ 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'
|
||||
}`;
|
||||
@@ -352,10 +355,15 @@ 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(
|
||||
@@ -367,17 +375,45 @@ 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: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retries,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
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', 'sh', '-c', networkReadyCheckCommand], { env })
|
||||
);
|
||||
return customNetworkCheckResult.ok;
|
||||
}
|
||||
|
||||
const pingResult = await asyncResult(
|
||||
spawn('adb', ['-s', serialId, 'shell', 'ping', '-c', '1', '-W', '1', '1.1.1.1'], {
|
||||
env,
|
||||
})
|
||||
);
|
||||
return pingResult.ok;
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
serialId,
|
||||
env,
|
||||
@@ -419,18 +455,13 @@ export namespace AndroidEmulatorUtils {
|
||||
return { outputPath };
|
||||
}
|
||||
|
||||
export async function deleteAsync({
|
||||
export async function stopAsync({
|
||||
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(
|
||||
@@ -443,12 +474,40 @@ export namespace AndroidEmulatorUtils {
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
|
||||
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 });
|
||||
}
|
||||
|
||||
export async function startScreenRecordingAsync({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
expect(stdout).toContain('data');
|
||||
|
||||
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
|
||||
await AndroidEmulatorUtils.stopAsync({
|
||||
serialId,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromise);
|
||||
|
||||
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
|
||||
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(stdoutClone).toContain('data');
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
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({
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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 by pinging 1.1.1.1 with timeout', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'ping' && args[8] === '1.1.1.1') {
|
||||
return { stdout: '1 packets transmitted, 1 received', 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', 'ping', '-c', '1', '-W', '1', '1.1.1.1'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
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] === 'ping') {
|
||||
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] === 'sh' && args[4] === '-c' && args[5] === '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', 'sh', '-c', 'exit 0'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'ping', '-c', '1', '-W', '1', '1.1.1.1'],
|
||||
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,4 +1,5 @@
|
||||
import { UserFacingError } from '../errors';
|
||||
import { BuildMode } from '../common';
|
||||
import { ErrorCode, ExpoErrorType, UserFacingError, toBuildError } from '../errors';
|
||||
|
||||
describe(UserFacingError, () => {
|
||||
it('supports docsUrl in options', () => {
|
||||
@@ -28,3 +29,22 @@ describe(UserFacingError, () => {
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe(toBuildError, () => {
|
||||
it('converts UserFacingError to a user-type BuildError', () => {
|
||||
const err = toBuildError(new UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'user message'));
|
||||
|
||||
expect(err.type).toBe(ExpoErrorType.USER);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
expect(err.userFacingMessage).toBe('user message');
|
||||
});
|
||||
|
||||
it('maps unknown custom errors to UNKNOWN_CUSTOM_BUILD_ERROR', () => {
|
||||
const err = toBuildError(new Error('oops'), { mode: BuildMode.CUSTOM });
|
||||
|
||||
expect(err.type).toBe(ExpoErrorType.SYSTEM);
|
||||
expect(err.errorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BuildMode } from './common';
|
||||
import { BuildPhase, buildPhaseDisplayName } from './logs';
|
||||
|
||||
export enum ErrorCode {
|
||||
@@ -10,6 +11,11 @@ export enum ErrorCode {
|
||||
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
|
||||
}
|
||||
|
||||
export enum ExpoErrorType {
|
||||
USER = 'USER',
|
||||
SYSTEM = 'SYSTEM',
|
||||
}
|
||||
|
||||
export interface ExternalBuildError {
|
||||
errorCode: string;
|
||||
message: string;
|
||||
@@ -17,32 +23,52 @@ export interface ExternalBuildError {
|
||||
buildPhase?: BuildPhase;
|
||||
}
|
||||
|
||||
interface BuildErrorDetails {
|
||||
interface ExpoErrorDetails {
|
||||
type: ExpoErrorType;
|
||||
errorCode: string;
|
||||
userFacingMessage: string;
|
||||
userFacingErrorCode: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class BuildError extends Error {
|
||||
interface BuildErrorDetails {
|
||||
type?: ExpoErrorType;
|
||||
errorCode: string;
|
||||
userFacingMessage: string;
|
||||
userFacingErrorCode: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class ExpoError extends Error {
|
||||
public type: ExpoErrorType;
|
||||
public errorCode: string;
|
||||
public userFacingMessage: string;
|
||||
public userFacingErrorCode: string;
|
||||
public docsUrl?: string;
|
||||
public innerError?: Error;
|
||||
public buildPhase?: BuildPhase;
|
||||
public metadata: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, details: BuildErrorDetails) {
|
||||
super(message);
|
||||
constructor(message: string, details: ExpoErrorDetails) {
|
||||
super(message, { cause: details.cause });
|
||||
this.type = details.type;
|
||||
this.errorCode = details.errorCode;
|
||||
this.userFacingErrorCode = details.userFacingErrorCode;
|
||||
this.userFacingMessage = details.userFacingMessage;
|
||||
this.docsUrl = details.docsUrl;
|
||||
this.innerError = details.innerError;
|
||||
this.buildPhase = details.buildPhase;
|
||||
this.metadata = details.metadata ?? {};
|
||||
}
|
||||
|
||||
public format(): ExternalBuildError {
|
||||
return {
|
||||
errorCode: this.userFacingErrorCode,
|
||||
@@ -53,19 +79,66 @@ export class BuildError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError extends Error {
|
||||
public docsUrl?: string;
|
||||
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string,
|
||||
options?: { docsUrl?: string; cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
this.docsUrl = options?.docsUrl;
|
||||
export class BuildError extends ExpoError {
|
||||
constructor(message: string, details: BuildErrorDetails) {
|
||||
super(message, {
|
||||
...details,
|
||||
type: details.type ?? ExpoErrorType.SYSTEM,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface UserErrorOptions {
|
||||
docsUrl?: string;
|
||||
cause?: unknown;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UserError extends ExpoError {
|
||||
constructor(errorCode: string, message: string, options?: UserErrorOptions) {
|
||||
super(message, {
|
||||
type: ExpoErrorType.USER,
|
||||
errorCode,
|
||||
userFacingErrorCode: errorCode,
|
||||
userFacingMessage: message,
|
||||
docsUrl: options?.docsUrl,
|
||||
innerError: options?.cause instanceof Error ? options.cause : undefined,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface SystemErrorOptions {
|
||||
userFacingErrorCode?: string;
|
||||
userFacingMessage?: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
metadata?: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class SystemError extends ExpoError {
|
||||
constructor(errorCode: string, message: string, options?: SystemErrorOptions) {
|
||||
super(message, {
|
||||
type: ExpoErrorType.SYSTEM,
|
||||
errorCode,
|
||||
userFacingErrorCode: options?.userFacingErrorCode ?? errorCode,
|
||||
userFacingMessage: options?.userFacingMessage ?? message,
|
||||
docsUrl: options?.docsUrl,
|
||||
innerError: options?.innerError,
|
||||
buildPhase: options?.buildPhase,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError extends UserError {}
|
||||
|
||||
export class UnknownError extends UserFacingError {
|
||||
constructor(buildPhase?: BuildPhase) {
|
||||
super(
|
||||
@@ -106,3 +179,38 @@ export class CredentialsDistCertMismatchError extends UserFacingError {
|
||||
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
|
||||
}
|
||||
}
|
||||
|
||||
export function toBuildError(
|
||||
error: unknown,
|
||||
options?: {
|
||||
mode?: BuildMode;
|
||||
buildPhase?: BuildPhase;
|
||||
}
|
||||
): BuildError {
|
||||
if (error instanceof BuildError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof ExpoError) {
|
||||
return new BuildError(error.message, {
|
||||
type: error.type,
|
||||
errorCode: error.errorCode,
|
||||
userFacingErrorCode: error.userFacingErrorCode,
|
||||
userFacingMessage: error.userFacingMessage,
|
||||
docsUrl: error.docsUrl,
|
||||
innerError: error.innerError,
|
||||
buildPhase: error.buildPhase ?? options?.buildPhase,
|
||||
metadata: error.metadata,
|
||||
cause: error.cause,
|
||||
});
|
||||
}
|
||||
|
||||
const isCustomMode =
|
||||
options?.mode === BuildMode.CUSTOM || options?.mode === BuildMode.REPACK;
|
||||
const fallback = isCustomMode ? new UnknownCustomBuildError() : new UnknownBuildError();
|
||||
fallback.buildPhase = options?.buildPhase;
|
||||
if (error && typeof error === 'object') {
|
||||
fallback.innerError = error as Error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
+91
-90
@@ -178,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -425,7 +425,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -444,7 +444,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -461,7 +461,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -500,7 +500,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -521,7 +521,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -556,7 +556,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -608,7 +608,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -637,7 +637,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -663,7 +663,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -717,7 +717,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -736,7 +736,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -755,7 +755,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -772,7 +772,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -793,7 +793,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -814,7 +814,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -836,7 +836,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -856,7 +856,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -878,7 +878,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -900,7 +900,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -933,7 +933,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -956,7 +956,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -977,7 +977,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -994,7 +994,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1012,7 +1012,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1040,7 +1040,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1065,7 +1065,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1086,7 +1086,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1107,7 +1107,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1144,7 +1144,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1164,7 +1164,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1185,7 +1185,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1206,7 +1206,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1220,7 +1220,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1234,7 +1234,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1264,7 +1264,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1289,7 +1289,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1311,7 +1311,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1336,7 +1336,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1362,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1384,7 +1384,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1406,7 +1406,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1438,7 +1438,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1482,7 +1482,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1512,7 +1512,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1621,7 +1621,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1638,7 +1638,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1655,7 +1655,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1706,7 +1706,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1720,7 +1720,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1743,7 +1743,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1766,7 +1766,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1784,7 +1784,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1819,7 +1819,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1841,7 +1841,8 @@ FLAGS
|
||||
--emit-metadata Emit "eas-update-metadata.json" in the bundle folder with detailed information about
|
||||
the generated updates
|
||||
--environment=<value> Environment to use for the server-side defined EAS environment variables during
|
||||
command execution, e.g. "production", "preview", "development"
|
||||
command execution, e.g. "production", "preview", "development". Required for
|
||||
projects using Expo SDK 55 or greater.
|
||||
--input-dir=<value> [default: dist] Location of the bundle
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
@@ -1859,7 +1860,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1872,14 +1873,14 @@ USAGE
|
||||
FLAGS
|
||||
-p, --platform=(android|ios|all) [default: all] Platform to configure
|
||||
--environment=<value> Environment to use for the server-side defined EAS environment variables during
|
||||
command execution, e.g. "production", "preview", "development"
|
||||
command execution, e.g. "production", "preview", "development".
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1900,7 +1901,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1924,7 +1925,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1949,7 +1950,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1985,7 +1986,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2012,7 +2013,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2040,7 +2041,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2061,7 +2062,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2081,7 +2082,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2102,7 +2103,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2123,7 +2124,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2143,7 +2144,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2161,7 +2162,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2183,7 +2184,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2200,7 +2201,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2320,7 +2321,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2340,7 +2341,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2364,7 +2365,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run [FILE]`
|
||||
|
||||
@@ -2404,7 +2405,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2429,7 +2430,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2457,7 +2458,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2477,7 +2478,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2499,5 +2500,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.0.4",
|
||||
"version": "18.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -65,6 +65,15 @@ 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.',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 = YAML.parse(yamlConfig);
|
||||
const parsed = parsedYamlFromWorkflowContents({ yamlConfig });
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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,6 +11,7 @@ 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']);
|
||||
|
||||
@@ -162,11 +163,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -173,20 +173,19 @@ export default class Go extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
Log.log(
|
||||
chalk.bold(
|
||||
`Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/personal-expo-go')}`
|
||||
`Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/deploy-expo-go-testflight')}`
|
||||
)
|
||||
);
|
||||
|
||||
const { flags } = await this.parse(Go);
|
||||
|
||||
const spinner = ora('Logging in to Expo...').start();
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
analytics,
|
||||
} = await this.getContextAsync(Go, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
|
||||
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
|
||||
|
||||
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
|
||||
const appName = flags.name ?? 'My Expo Go';
|
||||
@@ -551,12 +550,34 @@ export default class Go extends EasCommand {
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRunId: string
|
||||
): Promise<WorkflowRunStatus> {
|
||||
const buildSpinner = ora('Building Expo Go').start();
|
||||
const EXPECTED_BUILD_DURATION_SECONDS = 5 * 60;
|
||||
const EXPECTED_SUBMIT_DURATION_SECONDS = 2 * 60;
|
||||
const buildStartTime = Date.now();
|
||||
let submitStartTime: number | null = null;
|
||||
|
||||
const buildSpinner = ora(
|
||||
this.formatSpinnerText('Building Expo Go', EXPECTED_BUILD_DURATION_SECONDS, buildStartTime)
|
||||
).start();
|
||||
let submitSpinner: ReturnType<typeof ora> | null = null;
|
||||
let buildCompleted = false;
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
if (!buildCompleted) {
|
||||
buildSpinner.text = this.formatSpinnerText(
|
||||
'Building Expo Go',
|
||||
EXPECTED_BUILD_DURATION_SECONDS,
|
||||
buildStartTime
|
||||
);
|
||||
}
|
||||
if (submitSpinner && submitStartTime) {
|
||||
submitSpinner.text = this.formatSpinnerText(
|
||||
'Submitting to TestFlight',
|
||||
EXPECTED_SUBMIT_DURATION_SECONDS,
|
||||
submitStartTime
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
@@ -580,7 +601,14 @@ export default class Go extends EasCommand {
|
||||
}
|
||||
|
||||
if (buildCompleted && submitSpinner === null && submitJob) {
|
||||
submitSpinner = ora('Submitting to TestFlight').start();
|
||||
submitStartTime = Date.now();
|
||||
submitSpinner = ora(
|
||||
this.formatSpinnerText(
|
||||
'Submitting to TestFlight',
|
||||
EXPECTED_SUBMIT_DURATION_SECONDS,
|
||||
submitStartTime
|
||||
)
|
||||
).start();
|
||||
}
|
||||
|
||||
if (workflowRun.status === WorkflowRunStatus.Success) {
|
||||
@@ -607,4 +635,21 @@ export default class Go extends EasCommand {
|
||||
await sleepAsync(10 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private formatSpinnerText(
|
||||
label: string,
|
||||
expectedDurationSeconds: number,
|
||||
startTime: number
|
||||
): string {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
|
||||
const remainingSeconds = Math.max(0, expectedDurationSeconds - elapsedSeconds);
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return `${label} (almost done...)`;
|
||||
}
|
||||
|
||||
const minutes = Math.ceil(remainingSeconds / 60);
|
||||
const unit = minutes === 1 ? 'minute' : 'minutes';
|
||||
return `${label} (~${minutes} ${unit} remaining)`;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ensureEASUpdateIsConfiguredAsync,
|
||||
ensureEASUpdateIsConfiguredInEasJsonAsync,
|
||||
} from '../../update/configure';
|
||||
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
|
||||
|
||||
export default class UpdateConfigure extends EasCommand {
|
||||
static override description = 'configure the project to support EAS Update';
|
||||
@@ -43,11 +42,6 @@ export default class UpdateConfigure extends EasCommand {
|
||||
withServerSideEnvironment: flags['environment'] ?? null,
|
||||
});
|
||||
|
||||
assertEnvironmentFlagForSdk55OrGreater({
|
||||
sdkVersion: exp.sdkVersion,
|
||||
environment: flags['environment'],
|
||||
});
|
||||
|
||||
Log.log(
|
||||
'💡 The following process will configure your project to use EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
|
||||
);
|
||||
|
||||
@@ -8,7 +8,10 @@ import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasUpdateEnvironmentRequiredFlag,
|
||||
} from '../../commandUtils/flags';
|
||||
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import fetch from '../../fetch';
|
||||
@@ -191,7 +194,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasUpdateEnvironmentFlag,
|
||||
...EasUpdateEnvironmentRequiredFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export class CreateProvisioningProfile {
|
||||
private async maybeGetUserProvidedAsync(
|
||||
ctx: CredentialsContext
|
||||
): Promise<ProvisioningProfile | null> {
|
||||
if (ctx.nonInteractive) {
|
||||
if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) {
|
||||
return null;
|
||||
}
|
||||
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
|
||||
|
||||
@@ -162,7 +162,7 @@ Remember that Apple Distribution Certificates are not application specific!
|
||||
export async function provideOrGenerateDistributionCertificateAsync(
|
||||
ctx: CredentialsContext
|
||||
): Promise<DistributionCertificate> {
|
||||
if (!ctx.nonInteractive) {
|
||||
if (!ctx.nonInteractive && !ctx.autoAcceptCredentialReuse) {
|
||||
const userProvided = await promptForDistCertAsync(ctx);
|
||||
if (userProvided) {
|
||||
if (!ctx.appStore.authCtx) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.0.2",
|
||||
"version": "18.1.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,7 +26,7 @@
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.0.2",
|
||||
"@expo/build-tools": "18.1.0",
|
||||
"@expo/eas-build-job": "18.0.2",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.0.2",
|
||||
"version": "18.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.0.2",
|
||||
"@expo/build-tools": "18.1.0",
|
||||
"@expo/config-plugins": "8.0.8",
|
||||
"@expo/config-types": "52.0.1",
|
||||
"@expo/downloader": "18.0.1",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Builders } from '@expo/build-tools';
|
||||
import { Builders, runGenericJobAsync } from '@expo/build-tools';
|
||||
import { ArchiveSourceType, BuildTrigger, Generic, errors } from '@expo/eas-build-job';
|
||||
import { result } from '@expo/results';
|
||||
import { hostname } from 'os';
|
||||
import { setTimeout as setTimeoutAsync } from 'timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
@@ -10,9 +12,32 @@ import { cleanUpWorkingdir, prepareWorkingdir } from '../workingdir';
|
||||
import startWsServer from '../ws';
|
||||
|
||||
const buildId = 'e9b99e52-fb74-4927-be63-33d7447ddfd4';
|
||||
const genericJobRunErrorCode = 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND';
|
||||
|
||||
jest.setTimeout(30 * 1000);
|
||||
|
||||
function createTestGenericJob(): Generic.Job {
|
||||
return {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
|
||||
},
|
||||
secrets: {
|
||||
robotAccessToken: 'token',
|
||||
environmentSecrets: [],
|
||||
},
|
||||
expoDevUrl: 'https://expo.dev',
|
||||
builderEnvironment: {
|
||||
image: 'ubuntu-22.04',
|
||||
env: {},
|
||||
},
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
|
||||
appId: '8f89da11-f2d1-4db4-b2b5-0d55af4ca4f6',
|
||||
steps: [{ run: 'echo hello' }],
|
||||
};
|
||||
}
|
||||
|
||||
// Mock @expo/build-tools at the library boundary
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
const actual = jest.requireActual('@expo/build-tools');
|
||||
@@ -34,6 +59,10 @@ jest.mock('@expo/build-tools', () => {
|
||||
};
|
||||
}),
|
||||
},
|
||||
runGenericJobAsync: jest.fn(async () => ({
|
||||
runResult: result(undefined),
|
||||
buildWorkflow: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
jest.mock('../service', () => {
|
||||
@@ -291,6 +320,145 @@ describe('State sync mechanism', () => {
|
||||
expect(message.applicationArchiveName).toBe(`application-${buildId}`);
|
||||
expect(message.type).toBe('state-response');
|
||||
expect(message.status).toBe('error');
|
||||
expect(message.internalErrorCode).toBe(errors.ErrorCode.SERVER_ERROR);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
stateResponsePromiseResolve();
|
||||
}
|
||||
});
|
||||
const openPromise = helper.onOpen();
|
||||
helper.onMessage(onMessage);
|
||||
|
||||
await openPromise;
|
||||
messageTimeout = setTimeout(() => {
|
||||
unreachableCode('state-response timeout');
|
||||
}, 3000);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'state-query', buildId }));
|
||||
logger.debug('sent state-query');
|
||||
|
||||
await stateResponsePromise;
|
||||
|
||||
expect(helper.onErrorCb).not.toHaveBeenCalled();
|
||||
expect(helper.onOpenCb).toHaveBeenCalled();
|
||||
expect(helper.onMessageCb).toHaveBeenCalled();
|
||||
expect(helper.onCloseCb).not.toHaveBeenCalled();
|
||||
ws.close();
|
||||
clearTimeout(messageTimeout);
|
||||
});
|
||||
|
||||
it('should expose user-facing error from generic job', async () => {
|
||||
jest.mocked(runGenericJobAsync).mockResolvedValueOnce({
|
||||
runResult: result(
|
||||
new errors.UserFacingError(
|
||||
genericJobRunErrorCode,
|
||||
'ASC app was not found for this account.'
|
||||
)
|
||||
),
|
||||
buildWorkflow: {} as any,
|
||||
});
|
||||
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const dispatchHelper = new WsHelper(dispatchWS);
|
||||
await dispatchHelper.onOpen();
|
||||
dispatchWS.send(
|
||||
JSON.stringify({
|
||||
type: 'dispatch',
|
||||
buildId,
|
||||
job: createTestGenericJob(),
|
||||
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
|
||||
metadata: {
|
||||
trackingContext: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
dispatchWS.close();
|
||||
await dispatchHelper.onClose();
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const helper = new WsHelper(ws);
|
||||
|
||||
let stateResponsePromiseResolve: () => void;
|
||||
const stateResponsePromise = new Promise<void>(res => {
|
||||
stateResponsePromiseResolve = res;
|
||||
});
|
||||
const onMessage = jest.fn((message: any) => {
|
||||
clearTimeout(messageTimeout);
|
||||
try {
|
||||
expect(message).toBeTruthy();
|
||||
expect(message.type).toBe('state-response');
|
||||
expect(message.status).toBe('error');
|
||||
expect(message.externalBuildError).toEqual({
|
||||
errorCode: genericJobRunErrorCode,
|
||||
message: 'ASC app was not found for this account.',
|
||||
});
|
||||
expect(message.internalErrorCode).toBe(genericJobRunErrorCode);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
stateResponsePromiseResolve();
|
||||
}
|
||||
});
|
||||
const openPromise = helper.onOpen();
|
||||
helper.onMessage(onMessage);
|
||||
|
||||
await openPromise;
|
||||
messageTimeout = setTimeout(() => {
|
||||
unreachableCode('state-response timeout');
|
||||
}, 3000);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'state-query', buildId }));
|
||||
|
||||
await stateResponsePromise;
|
||||
|
||||
expect(helper.onErrorCb).not.toHaveBeenCalled();
|
||||
expect(helper.onOpenCb).toHaveBeenCalled();
|
||||
expect(helper.onMessageCb).toHaveBeenCalled();
|
||||
expect(helper.onCloseCb).not.toHaveBeenCalled();
|
||||
ws.close();
|
||||
clearTimeout(messageTimeout);
|
||||
});
|
||||
|
||||
it('should include user-facing error details when builder throws UserFacingError', async () => {
|
||||
jest.mocked(Builders.androidBuilder).mockImplementation(async () => {
|
||||
throw new errors.UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'This is user-visible');
|
||||
});
|
||||
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const dispatchHelper = new WsHelper(dispatchWS);
|
||||
await dispatchHelper.onOpen();
|
||||
dispatchWS.send(
|
||||
JSON.stringify({
|
||||
type: 'dispatch',
|
||||
buildId,
|
||||
job: createTestAndroidJob(),
|
||||
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
|
||||
metadata: {
|
||||
trackingContext: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
dispatchWS.close();
|
||||
await dispatchHelper.onClose();
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
|
||||
const helper = new WsHelper(ws);
|
||||
|
||||
let stateResponsePromiseResolve: () => void;
|
||||
const stateResponsePromise = new Promise<void>(res => {
|
||||
stateResponsePromiseResolve = res;
|
||||
});
|
||||
const onMessage = jest.fn((message: any) => {
|
||||
logger.debug('message received');
|
||||
clearTimeout(messageTimeout);
|
||||
try {
|
||||
expect(message).toBeTruthy();
|
||||
expect(message.type).toBe('state-response');
|
||||
expect(message.status).toBe('error');
|
||||
expect(message.externalBuildError).toEqual({
|
||||
errorCode: 'EAS_BUILD_TEST_USER_ERROR',
|
||||
message: 'This is user-visible',
|
||||
});
|
||||
expect(message.internalErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
@@ -111,6 +111,8 @@ export default class BuildService {
|
||||
|
||||
public async finishError(err: errors.BuildError, artifacts: Artifacts | null): Promise<void> {
|
||||
logger.error({ err }, 'Job finished with error');
|
||||
const websocketErrorCode =
|
||||
err.type === errors.ExpoErrorType.SYSTEM ? errors.ErrorCode.SERVER_ERROR : err.errorCode;
|
||||
|
||||
this.state.finish(Worker.Status.ERROR, {
|
||||
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
|
||||
@@ -128,7 +130,7 @@ export default class BuildService {
|
||||
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
|
||||
buildArtifactsName: artifacts?.BUILD_ARTIFACTS ?? null,
|
||||
externalBuildError: err.format(),
|
||||
internalErrorCode: err.errorCode,
|
||||
internalErrorCode: websocketErrorCode,
|
||||
});
|
||||
}
|
||||
this.checkForHangingWorker(isSocketClosed);
|
||||
@@ -308,35 +310,42 @@ export default class BuildService {
|
||||
await this.finishSuccess(artifacts);
|
||||
} catch (error: any) {
|
||||
const maybeArtifacts = (error.artifacts as Artifacts | undefined) ?? null;
|
||||
|
||||
const unknownError =
|
||||
'mode' in job && [BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)
|
||||
? new errors.UnknownCustomBuildError()
|
||||
: new errors.UnknownBuildError();
|
||||
const err = error instanceof errors.BuildError ? error : unknownError;
|
||||
const maybeRawError = error instanceof errors.BuildError ? error.innerError : error;
|
||||
const err = errors.toBuildError(error, {
|
||||
mode: 'mode' in job ? job.mode : undefined,
|
||||
});
|
||||
const maybeRawError = err.innerError ?? (error instanceof Error ? error : undefined);
|
||||
const maybeRawErrorWithOutput = maybeRawError as
|
||||
| (Error & { stdout?: string; stderr?: string })
|
||||
| undefined;
|
||||
|
||||
sentry.handleError(err.message, maybeRawError, {
|
||||
tags: {
|
||||
...(err.buildPhase ? { buildPhase: err.buildPhase } : {}),
|
||||
expoErrorType: err.type,
|
||||
errorCode: err.errorCode,
|
||||
userFacingErrorCode: err.userFacingErrorCode,
|
||||
...('type' in job ? { workflow: job.type } : {}),
|
||||
},
|
||||
extras: {
|
||||
buildId: this.buildId,
|
||||
...(maybeRawError.stdout ? { stdout: getLastNLines(100, maybeRawError.stdout) } : {}),
|
||||
...(maybeRawError.stderr ? { stderr: getLastNLines(100, maybeRawError.stderr) } : {}),
|
||||
...(maybeRawErrorWithOutput?.stdout
|
||||
? { stdout: getLastNLines(100, maybeRawErrorWithOutput.stdout) }
|
||||
: {}),
|
||||
...(maybeRawErrorWithOutput?.stderr
|
||||
? { stderr: getLastNLines(100, maybeRawErrorWithOutput.stderr) }
|
||||
: {}),
|
||||
},
|
||||
level: err.type === errors.ExpoErrorType.USER ? 'warning' : 'error',
|
||||
});
|
||||
|
||||
const robotAccessToken = job.secrets?.robotAccessToken;
|
||||
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
|
||||
let rawErrorMessage: string = '';
|
||||
if (maybeRawError.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
|
||||
if (maybeRawErrorWithOutput?.stderr) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stderr);
|
||||
}
|
||||
if (maybeRawError.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
|
||||
if (maybeRawErrorWithOutput?.stdout) {
|
||||
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stdout);
|
||||
}
|
||||
|
||||
await turtleFetch(
|
||||
|
||||
@@ -43,6 +43,11 @@ class State {
|
||||
}
|
||||
|
||||
public stateResponse(): WorkerMessage.StateResponse {
|
||||
const websocketErrorCode =
|
||||
this.userError?.type === errors.ExpoErrorType.SYSTEM
|
||||
? errors.ErrorCode.SERVER_ERROR
|
||||
: this.userError?.errorCode;
|
||||
|
||||
return {
|
||||
type: WorkerMessage.MessageType.STATE_RESPONSE,
|
||||
status: this.status,
|
||||
@@ -50,7 +55,7 @@ class State {
|
||||
applicationArchiveName: this.applicationArchiveName ?? null,
|
||||
...(this.userError && {
|
||||
externalBuildError: this.userError.format(),
|
||||
internalErrorCode: this.userError.errorCode,
|
||||
internalErrorCode: websocketErrorCode,
|
||||
}),
|
||||
abortReason: this.abortReason,
|
||||
};
|
||||
|
||||
@@ -2020,7 +2020,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.0.2, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.1.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -2055,6 +2055,7 @@ __metadata:
|
||||
bplist-parser: "npm:0.3.2"
|
||||
bunyan: "npm:^1.8.15"
|
||||
fast-glob: "npm:^3.3.2"
|
||||
fast-xml-parser: "npm:^4.4.1"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
gql.tada: "npm:^1.8.13"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2717,7 +2718,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.0.2"
|
||||
"@expo/build-tools": "npm:18.1.0"
|
||||
"@expo/config-plugins": "npm:8.0.8"
|
||||
"@expo/config-types": "npm:52.0.1"
|
||||
"@expo/downloader": "npm:18.0.1"
|
||||
@@ -10120,7 +10121,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.0.2"
|
||||
"@expo/build-tools": "npm:18.1.0"
|
||||
"@expo/eas-build-job": "npm:18.0.2"
|
||||
"@expo/logger": "npm:18.0.1"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
|
||||
Reference in new issue
Block a user