Compare commits

..
147 changed files with 5992 additions and 5585 deletions

No files matched your search

@@ -12,8 +12,6 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
+4 -22
View File
@@ -6,34 +6,16 @@ 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
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🐛 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 -3
View File
@@ -296,11 +296,9 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.1.0",
"version": "18.0.4",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^4.22.85",
"oclif": "^3.9.0",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.1.0",
"version": "18.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -53,7 +53,6 @@
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
@@ -19,7 +19,3 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -24,8 +24,7 @@ export async function installDependenciesAsync({
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
break;
}
case PackageManager.PNPM: {
@@ -45,12 +44,7 @@ export async function installDependenciesAsync({
];
}
} else {
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
}
break;
}
@@ -10,7 +10,6 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
@@ -54,7 +53,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
@@ -67,7 +65,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
@@ -21,7 +21,6 @@ import { createInternalEasMaestroTestFunction } from './functions/internalMaestr
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -82,8 +81,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -1,210 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -1,594 +0,0 @@
import { vol } from 'memfs';
import {
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
{
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
errorMessage: null,
duration: 10500,
retryCount: 0,
tags: [],
properties: {},
},
{
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Tap failed',
duration: 5000,
retryCount: 0,
tags: [],
properties: {},
},
])
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
it('extracts tags from JUnit properties and separates from other properties', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="tags" value="e2e, smoke"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tmp/maestro-reports-abc123/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'home',
status: 'passed',
duration: 10500,
errorMessage: null,
tags: [],
properties: {},
},
]);
});
it('parses a failed testcase with failure message', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Element not visible</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'login',
status: 'failed',
duration: 5000,
errorMessage: 'Element not visible',
tags: [],
properties: {},
},
]);
});
it('extracts properties from testcase', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
});
it('extracts tags from "tags" property as comma-separated values', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="tags" value="smoke, critical, auth"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('returns empty tags when no "tags" property exists (older Maestro)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual([]);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('parses multiple testcases across multiple testsuites (shards)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed' }),
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
])
);
});
it('parses multiple JUnit XML files in the same directory', async () => {
vol.fromJSON({
'/junit/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
});
it('handles missing time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('handles invalid time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="not_a_number" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBeNull();
});
it('extracts error message from <error> element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <error>Runtime exception occurred</error>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBe('Runtime exception occurred');
});
it('returns empty array when directory does not exist', async () => {
const results = await parseJUnitTestCases('/nonexistent');
expect(results).toEqual([]);
});
it('returns empty array when no XML files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([]);
});
it('skips invalid XML files gracefully', async () => {
vol.fromJSON({
'/junit/bad.xml': 'not xml at all',
'/junit/good.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
});
it('handles testcase with no properties element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({});
});
});
@@ -1,273 +0,0 @@
import { Client } from '@urql/core';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n');
const JUNIT_FAIL = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n');
const FLOW_AI = JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
});
describe(createReportMaestroTestResultsFunction, () => {
let mockMutationFn: jest.Mock;
let mockGraphqlClient: Client;
beforeEach(() => {
mockMutationFn = jest.fn();
mockGraphqlClient = {
mutation: jest.fn().mockReturnValue({
toPromise: mockMutationFn,
}),
} as unknown as Client;
});
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
...overrides?.staticContextContent,
},
});
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
junit_report_directory: '/junit',
tests_directory: '/tests',
...overrides?.callInputs,
},
});
}
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.workflowJobId).toBe('job-uuid-123');
expect(variables.input.testCaseResults).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'PASSED',
errorMessage: null,
duration: 10000,
retryCount: 0,
properties: {},
}),
]);
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="testCaseId" value="TC-001"/>',
' <property name="priority" value="high"/>',
' <property name="tags" value="e2e"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
expect(variables.input.testCaseResults[0].properties).toEqual({
testCaseId: 'TC-001',
priority: 'high',
});
});
it('reports failed flow with correct status and errorMessage', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_FAIL,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({
status: 'FAILED',
errorMessage: 'Tap failed',
duration: 5000,
})
);
});
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
// not optional), so it's always present in workflow jobs. graphqlClient is already
// initialized with the token in BuildContext constructor.
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
const step = createStep({
callInputs: { junit_report_directory: '' },
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const step = createStep({ env: {} });
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when no JUnit files found', async () => {
const step = createStep();
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('does not throw when GraphQL returns error', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
error: { message: 'Something went wrong' },
});
await createStep().executeAsync();
});
it('does not throw when mutation rejects', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockRejectedValue(new Error('Network error'));
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
},
});
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -1,177 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
});
@@ -1,97 +0,0 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -24,7 +24,6 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
@@ -82,10 +81,6 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
@@ -353,10 +348,6 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
@@ -414,9 +405,6 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
@@ -438,7 +426,7 @@ async function withCleanDeviceAsync<TResult>({
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
switch (platform) {
case 'ios': {
@@ -462,81 +450,28 @@ async function withCleanDeviceAsync<TResult>({
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(`Starting Android Emulator ${localDeviceName}...`);
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
localDeviceIdentifier = serialId;
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
@@ -567,14 +502,14 @@ async function withCleanDeviceAsync<TResult>({
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
env,
});
break;
@@ -1,264 +0,0 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
path: string;
status: 'passed' | 'failed';
errorMessage: string | null;
duration: number; // milliseconds
retryCount: number;
tags: string[];
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
tags: string[];
properties: Record<string, string>;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
// Ensure single-element arrays are always arrays
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
entries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
if (xmlFiles.length === 0) {
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
}
return results;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
return relative;
}
@@ -1,120 +0,0 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
createWorkflowDeviceTestCaseResults(input: $input) {
id
}
}
}
`);
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
passed: 'PASSED',
failed: 'FAILED',
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'report_maestro_test_results',
name: 'Report Maestro Test Results',
__metricsId: 'eas/report_maestro_test_results',
inputProviders: [
BuildStepInput.createProvider({
id: 'junit_report_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'tests_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
logger.info('Not running in a workflow job, skipping test results report');
return;
}
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
if (!junitDirectory) {
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
return;
}
const testCaseResults = flowResults.flatMap(f => {
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
if (!status) {
return [];
}
return [
{
name: f.name,
path: f.path,
status,
errorMessage: f.errorMessage,
duration: f.duration,
retryCount: f.retryCount,
tags: f.tags,
properties: f.properties,
},
];
});
const result = await ctx.graphqlClient
.mutation(CREATE_MUTATION, {
input: {
workflowJobId,
testCaseResults,
},
})
.toPromise();
if (result.error) {
logger.error({ error: result.error }, 'GraphQL error creating test case results');
return;
}
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
} catch (error) {
logger.error({ err: error }, 'Failed to create test case results');
}
},
});
}
@@ -9,9 +9,6 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -75,78 +72,25 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
logger.info('Creating emulator device');
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
logger.info('Starting emulator device');
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId,
});
logger.info(`${deviceName} is ready.`);
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -161,70 +105,27 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
for (let i = 0; i < count; i++) {
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let cloneSerialId = null;
try {
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
cloneSerialId = startResult.serialId;
logger.info('Starting emulator device');
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: cloneSerialId,
env,
timeoutMs,
logger,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
logger.warn(
{ err },
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (cloneSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: cloneSerialId,
deviceName: cloneIdentifier,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: cloneIdentifier,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
logger.info(`${cloneIdentifier} is ready.`);
}
}
},
@@ -1,5 +1,3 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildStepInput,
@@ -14,8 +12,6 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -105,21 +101,34 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info(
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
stepsCtx.logger.info('Reading App information...');
const appResponse = await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
relationships: {
app: {
data: {
type: 'apps',
id: appleAppIdentifier,
},
},
},
},
});
const buildUploadId = buildUploadResponse.data.id;
@@ -225,9 +234,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
@@ -240,14 +246,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
await setTimeout(2000);
continue;
}
@@ -266,50 +265,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
@@ -324,36 +279,6 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -11,43 +11,7 @@ type ApiSchema = {
};
};
const AscErrorResponseSchema = z.object({
errors: z
.array(
z.object({
id: z.string().optional(),
status: z.string().optional(),
code: z.string().optional(),
title: z.string().optional(),
detail: z.string().optional(),
source: z.unknown().optional(),
})
)
.min(1),
});
const GetApi = {
'/v1/apps': {
path: z.object({}),
request: z.object({
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
return opts.includes('bundleId') && opts.includes('name');
}),
limit: z.number().int().min(1).max(200).optional(),
}),
response: z.object({
data: z.array(
z.object({
type: z.literal('apps'),
id: z.string(),
attributes: z.object({
bundleId: z.string(),
name: z.string(),
}),
})
),
}),
},
'/v1/apps/:id': {
path: z.object({
id: z.string(),
@@ -258,13 +222,6 @@ const PatchApi = {
},
} satisfies ApiSchema;
export type AscApiClientGetApi = {
[Path in keyof typeof GetApi]: {
request: z.input<(typeof GetApi)[Path]['request']>;
response: z.output<(typeof GetApi)[Path]['response']>;
};
};
export type AscApiClientPostApi = {
[Path in keyof typeof PostApi]: {
request: z.input<(typeof PostApi)[Path]['request']>;
@@ -279,17 +236,6 @@ export type AscApiClientPatchApi = {
};
};
export class AscApiRequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
options?: { cause?: unknown }
) {
super(message, { cause: options?.cause });
}
}
export class AscApiClient {
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
private readonly token: string;
@@ -400,31 +346,12 @@ export class AscApiClient {
if (!response.ok) {
const text = await response.text();
const parsedAscErrorResponse = await asyncResult(
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
);
if (parsedAscErrorResponse.ok) {
throw new AscApiRequestError(
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
response.status,
parsedAscErrorResponse.value,
{ cause: response }
);
}
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
cause: response,
});
}
const text = await response.text();
const parsedJson = await asyncResult((async () => JSON.parse(text))());
if (!parsedJson.ok) {
throw new Error(
`Malformed JSON response from App Store Connect (${response.status}): ${text}`
);
}
const json = parsedJson.value;
const json = await response.json();
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
@@ -1,126 +0,0 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
AscApiClient,
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
} from './AscApiClient';
export namespace AscApiUtils {
export async function getAppInfoAsync({
client,
appleAppIdentifier,
}: {
client: Pick<AscApiClient, 'getAsync'>;
appleAppIdentifier: string;
}): Promise<AscApiClientGetApi['/v1/apps/:id']['response']> {
try {
return await client.getAsync(
'/v1/apps/:id',
{ 'fields[apps]': ['bundleId', 'name'] },
{ id: appleAppIdentifier }
);
} catch (error) {
const notFoundErrors =
error instanceof AscApiRequestError && error.status === 404
? error.responseJson.errors
: [];
const isAppNotFoundError =
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
if (!isAppNotFoundError) {
throw error;
}
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserFacingError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
(visibleAppsSummary
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
: ''),
{
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, AscApiRequestError } from '../AscApiClient';
import { AscApiClient } from '../AscApiClient';
// nock needs real fetch implementation
jest.unmock('node-fetch');
@@ -280,54 +280,4 @@ describe(AscApiClient, () => {
});
expect(scope.isDone()).toBeTruthy();
});
it('throws AscApiRequestError for structured ASC error payload', async () => {
const appId = '1491144534';
const responseFixture = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
title:
'The provided entity includes an attribute with a value that has already been used',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(409, responseFixture);
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toBeInstanceOf(AscApiRequestError);
});
it('throws regular Error for non-structured ASC error payload', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(503, 'Service unavailable');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.not.toBeInstanceOf(AscApiRequestError);
});
it('throws controlled error when success response body is not valid JSON', async () => {
const appId = '1491144534';
nock('https://api.appstoreconnect.apple.com')
.get(`/v1/apps/${appId}`)
.query({ 'fields[apps]': 'bundleId,name' })
.reply(200, 'not-json');
await expect(
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
});
});
@@ -1,190 +0,0 @@
import { AscApiRequestError } from '../AscApiClient';
import { AscApiUtils } from '../AscApiUtils';
describe('AscApiUtils', () => {
describe('getAppInfoAsync', () => {
it('returns app info when lookup succeeds', async () => {
const response = {
data: {
type: 'apps',
id: '1491144534',
attributes: {
name: 'Example App',
bundleId: 'com.example.app',
},
},
} as const;
const client = {
getAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1491144534' })
).resolves.toEqual(response);
});
it('throws UserFacingError with visible apps when app id is not found', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
detail: "There is no resource of type 'apps' with id '1234567890'",
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockResolvedValueOnce({
data: [
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
],
}),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
),
})
);
});
it('rethrows original not-found error when app-list lookup fails', async () => {
const notFoundPayload = {
errors: [
{
status: '404',
code: 'NOT_FOUND',
},
],
};
const notFoundError = new AscApiRequestError(
'Unexpected response (404) from App Store Connect',
404,
notFoundPayload
);
const listingError = new Error('listing failed');
const client = {
getAsync: jest
.fn()
.mockRejectedValueOnce(notFoundError)
.mockRejectedValueOnce(listingError),
};
await expect(
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
).rejects.toBe(notFoundError);
});
});
describe('createBuildUploadAsync', () => {
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
detail: 'The bundle version must be higher than the previously uploaded version.',
},
],
};
const duplicateError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(duplicateError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toEqual(
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
message: expect.stringContaining('Increment Build Number'),
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
})
);
});
it('returns response when upload initialization succeeds', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).resolves.toEqual(response);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
{
status: '409',
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
},
{
status: '409',
code: 'SOME_OTHER_ERROR',
},
],
};
const mixedError = new AscApiRequestError(
'Unexpected response (409) from App Store Connect',
409,
payload
);
const client = {
postAsync: jest.fn().mockRejectedValue(mixedError),
};
await expect(
AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
})
).rejects.toBe(mixedError);
});
});
});
@@ -1,5 +1,4 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
@@ -18,8 +17,6 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
export namespace AndroidEmulatorUtils {
const RETRY_INTERVAL_MS = 1_000;
export const defaultSystemImagePackage = `system-images;android-30;default;${
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
}`;
@@ -355,15 +352,10 @@ export namespace AndroidEmulatorUtils {
export async function waitForReadyAsync({
serialId,
env,
timeoutMs = 3 * 60 * 1_000,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
logger?: bunyan;
}): Promise<void> {
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
await retryAsync(
async () => {
const { stdout } = await spawn(
@@ -375,48 +367,17 @@ export namespace AndroidEmulatorUtils {
if (!stdout.startsWith('1')) {
throw new Error(`Emulator (${serialId}) boot has not completed.`);
}
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
if (!hasNetworkConnection) {
throw new Error(`Emulator (${serialId}) network is not ready.`);
}
},
{
// Retry every second for 3 minutes.
retryOptions: {
retries,
retryIntervalMs: RETRY_INTERVAL_MS,
retries: 3 * 60,
retryIntervalMs: 1_000,
},
logger,
}
);
}
async function hasNetworkConnectionAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
if (networkReadyCheckCommand) {
const customNetworkCheckResult = await asyncResult(
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
);
return customNetworkCheckResult.ok;
}
const netcatResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
)
);
return netcatResult.ok;
}
export async function collectLogsAsync({
serialId,
env,
@@ -458,13 +419,18 @@ export namespace AndroidEmulatorUtils {
return { outputPath };
}
export async function stopAsync({
export async function deleteAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
await retryAsync(
@@ -477,40 +443,12 @@ export namespace AndroidEmulatorUtils {
{
retryOptions: {
retries: 3 * 60,
retryIntervalMs: RETRY_INTERVAL_MS,
retryIntervalMs: 1_000,
},
}
);
}
export async function deleteAsync({
serialId,
deviceName,
env,
}: {
serialId?: AndroidDeviceSerialId;
deviceName?: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
let resolvedDeviceName = deviceName;
if (!resolvedDeviceName && serialId) {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
| AndroidVirtualDeviceName
| undefined;
}
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
const serialIdToStop =
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
if (serialIdToStop) {
await stopAsync({ serialId: serialIdToStop, env });
}
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
}
export async function startScreenRecordingAsync({
@@ -9,7 +9,6 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -105,10 +104,7 @@ describe('AndroidEmulatorUtils', () => {
});
expect(stdout).toContain('data');
await AndroidEmulatorUtils.stopAsync({
serialId,
env: process.env,
});
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
await asyncResult(emulatorPromise);
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
@@ -135,133 +131,12 @@ describe('AndroidEmulatorUtils', () => {
expect(stdoutClone).toContain('data');
await AndroidEmulatorUtils.deleteAsync({
deviceName,
serialId,
env: process.env,
});
await asyncResult(emulatorPromiseClone);
}, 60_000);
it('should delete a running emulator by device name', async () => {
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: process.env,
});
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
await asyncResult(emulatorPromise);
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
expect(devices.map(device => device.serialId)).not.toContain(serialId);
await expect(fs.promises.access(avdPath)).rejects.toThrow();
}, 60_000);
it('should retry when first startup forces network check command failure', async () => {
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
let attemptCounter = 0;
let sawNetworkNotReadyError = false;
await retryAsync(
async attemptCount => {
attemptCounter += 1;
const shouldFailNetworkCheck = attemptCount === 0;
const envForAttempt: NodeJS.ProcessEnv = {
...process.env,
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
? 'echo "network not ready" && exit 1'
: 'echo "network ready"',
};
let serialId: AndroidDeviceSerialId | null = null;
let emulatorPromise: Promise<unknown> | null = null;
try {
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: envForAttempt,
timeoutMs: 30_000,
logger: createMockLogger({ logToConsole: true }),
});
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await emulatorPromise;
} catch (err: any) {
if (err?.message?.includes('network is not ready')) {
sawNetworkNotReadyError = true;
}
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (cleanupError) {
console.warn('Cleanup failed during retry test', cleanupError);
}
if (emulatorPromise) {
await emulatorPromise;
}
throw err;
}
},
{
logger: createMockLogger({ logToConsole: true }),
retryOptions: {
retries: 1,
retryIntervalMs: 1_000,
},
}
);
expect(sawNetworkNotReadyError).toBe(true);
expect(attemptCounter).toBe(2);
}, 360_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -1,161 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
timeoutMs: 60_000,
});
expect(mockedRetryAsync).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
retryOptions: expect.objectContaining({
retries: 59,
retryIntervalMs: 1_000,
}),
})
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
});
it('throws when network is unavailable despite completed boot', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc') {
throw new Error('network unreachable');
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await expect(
AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
})
).rejects.toThrow('network is not ready');
});
it('uses overridden network readiness command when provided', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'exit 0') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: {
...process.env,
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
},
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'exit 0'],
expect.objectContaining({
env: expect.objectContaining({
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
}),
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
expect.anything()
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[2] === 'emu' && args[3] === 'kill') {
return { stdout: '', stderr: '' } as any;
}
if (args[0] === 'devices') {
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.stopAsync({
serialId: 'emulator-5554' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
env: process.env,
});
});
});
describe(AndroidEmulatorUtils.deleteAsync, () => {
it('stops by serial id and then deletes the AVD', async () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await AndroidEmulatorUtils.deleteAsync({
serialId: 'emulator-5554' as any,
deviceName: 'eas-simulator-1' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
['delete', 'avd', '-n', 'eas-simulator-1'],
{
env: process.env,
}
);
});
});
});
+90 -91
View File
@@ -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.1.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.4/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.1.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1841,8 +1841,7 @@ 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". Required for
projects using Expo SDK 55 or greater.
command execution, e.g. "production", "preview", "development"
--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.
@@ -1860,7 +1859,7 @@ DESCRIPTION
publish an update group
```
_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)_
_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)_
## `eas update:configure`
@@ -1873,14 +1872,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.1.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1901,7 +1900,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.1.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1925,7 +1924,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.1.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1950,7 +1949,7 @@ DESCRIPTION
view the recent updates
```
_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)_
_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)_
## `eas update:republish`
@@ -1986,7 +1985,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.1.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2013,7 +2012,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.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2041,7 +2040,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.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2062,7 +2061,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.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2082,7 +2081,7 @@ DESCRIPTION
update group details
```
_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)_
_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)_
## `eas upload`
@@ -2103,7 +2102,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.1.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2124,7 +2123,7 @@ DESCRIPTION
create a webhook
```
_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)_
_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)_
## `eas webhook:delete [ID]`
@@ -2144,7 +2143,7 @@ DESCRIPTION
delete a webhook
```
_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)_
_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)_
## `eas webhook:list`
@@ -2162,7 +2161,7 @@ DESCRIPTION
list webhooks
```
_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)_
_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)_
## `eas webhook:update`
@@ -2184,7 +2183,7 @@ DESCRIPTION
update a webhook
```
_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)_
_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)_
## `eas webhook:view ID`
@@ -2201,7 +2200,7 @@ DESCRIPTION
view a webhook
```
_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)_
_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)_
## `eas whoami`
@@ -2321,7 +2320,7 @@ DESCRIPTION
to cancel.
```
_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)_
_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)_
## `eas workflow:create [NAME]`
@@ -2341,7 +2340,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.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2365,7 +2364,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.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2405,7 +2404,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.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2430,7 +2429,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.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2458,7 +2457,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.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2478,7 +2477,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.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2500,5 +2499,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.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.0.4/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+13 -6
View File
@@ -1,10 +1,17 @@
#!/usr/bin/env node
(async () => {
const oclif = require('@oclif/core');
const oclif = require('@oclif/core');
const path = require('path');
// Keep dev entrypoint failures verbose for local debugging.
oclif.settings.debug = true;
const project = path.join(__dirname, '..', 'tsconfig.json');
await oclif.execute({ development: true, dir: __dirname });
})();
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
require('@oclif/core')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.1.0",
"version": "18.0.4",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -65,8 +65,8 @@
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.2",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
@@ -203,6 +203,9 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
@@ -133,21 +133,11 @@ export function mockTestCommand<T extends Command>(
argv: string[],
ctx: any
): T {
const config = getMockOclifConfig(__dirname);
const cmd = new commandConstructor(argv, config);
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export function getMockOclifConfig(root = __dirname): Config {
const config = new Config({ root });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import { ExitError } from '@oclif/core/lib/errors';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';
@@ -8,8 +8,6 @@ import { validatePNGsForManagedProjectAsync } from '../validate';
const fixturesPath = path.join(__dirname, 'fixtures');
const { ExitError } = Errors;
describe(validatePNGsForManagedProjectAsync, () => {
it('does not validate PNGs for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
@@ -42,7 +42,7 @@ beforeEach(() => {
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = (): any => {
const createTestEasCommand = (): typeof EasCommand => {
class TestEasCommand extends EasCommand {
async runAsync(): Promise<void> {}
}
+9 -18
View File
@@ -28,28 +28,28 @@ export const EASMultiEnvironmentFlag = {
};
export const EASVariableFormatFlag = {
format: Flags.option({
format: Flags.enum({
description: 'Output format',
options: ['long', 'short'] as const,
options: ['long', 'short'],
default: 'short',
})(),
}),
};
export const EASVariableVisibilityFlag = {
visibility: Flags.option({
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
description: 'Visibility of the variable',
options: ['plaintext', 'sensitive', 'secret'] as const,
})(),
options: ['plaintext', 'sensitive', 'secret'],
}),
};
export type EASEnvironmentVariableScopeFlagValue = 'project' | 'account';
export const EASEnvironmentVariableScopeFlag = {
scope: Flags.option({
scope: Flags.enum<EASEnvironmentVariableScopeFlagValue>({
description: 'Scope for the variable',
options: ['project', 'account'] as const,
options: ['project', 'account'],
default: 'project',
})(),
}),
};
export const EASNonInteractiveFlag = {
@@ -65,15 +65,6 @@ export const EasJsonOnlyFlag = {
};
export const EasUpdateEnvironmentFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development".',
required: false,
default: undefined,
}),
};
export const EasUpdateEnvironmentRequiredFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development". Required for projects using Expo SDK 55 or greater.',
@@ -1,4 +1,5 @@
import { Flags, Interfaces } from '@oclif/core';
import { Flags } from '@oclif/core';
import { OptionFlag } from '@oclif/core/lib/interfaces';
import { EasNonInteractiveAndJsonFlags } from './flags';
@@ -34,21 +35,25 @@ const parseFlagInputStringAsInteger = (
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
description,
}: {
defaultTo: number;
limit: number;
}): Interfaces.OptionFlag<number | undefined> =>
description?: string;
}): OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
description:
description ??
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async (input: string) => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
export const EasPaginatedQueryFlags = {
offset: Flags.integer({
description: 'Start queries from specified index. Use for paginating results. Defaults to 0.',
// eslint-disable-next-line async-protect/async-suffix
parse: async (input: string) =>
parse: async input =>
parseFlagInputStringAsInteger(input, 'offset', 0, Number.MAX_SAFE_INTEGER),
}),
limit: getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 }),
@@ -1,63 +0,0 @@
import { parsedYamlFromWorkflowContents } from '../parse';
describe('parsedYamlFromWorkflowContents', () => {
it('parses YAML workflow contents', () => {
const yamlConfig = `
jobs:
test:
type: custom
`;
expect(parsedYamlFromWorkflowContents({ yamlConfig })).toEqual({
jobs: {
test: {
type: 'custom',
},
},
});
});
it('supports shared env merge keys across multiple jobs', () => {
const yamlConfig = `
shared_env: &shared_env
EXPO_TOKEN: token
CI: "1"
jobs:
android:
type: custom
env:
<<: *shared_env
PLATFORM: android
ios:
type: custom
env:
<<: *shared_env
PLATFORM: ios
`;
expect(parsedYamlFromWorkflowContents({ yamlConfig })).toEqual({
shared_env: {
EXPO_TOKEN: 'token',
CI: '1',
},
jobs: {
android: {
type: 'custom',
env: {
EXPO_TOKEN: 'token',
CI: '1',
PLATFORM: 'android',
},
},
ios: {
type: 'custom',
env: {
EXPO_TOKEN: 'token',
CI: '1',
PLATFORM: 'ios',
},
},
},
});
});
});
@@ -1,10 +1,10 @@
import * as YAML from 'yaml';
import { z } from 'zod';
import { DefaultEnvironment } from '../../build/utils/environment';
import { booleanLike, stringLike } from '../../credentials/ios/types';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { parsedYamlFromWorkflowContents } from './parse';
const inputExtraProperties = {
description: stringLike.optional().describe('Description of the input'),
@@ -83,7 +83,7 @@ export function parseWorkflowInputsFromYaml(
yamlConfig: string
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
try {
const parsed = parsedYamlFromWorkflowContents({ yamlConfig });
const parsed = YAML.parse(yamlConfig);
return z
.record(z.string(), WorkflowDispatchInputZ)
.default({})
@@ -1,9 +0,0 @@
import * as YAML from 'yaml';
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
return YAML.parse(workflowFileContents.yamlConfig, {
// Keep in sync with backend parser options.
merge: true,
maxAliasCount: 50,
});
}
@@ -11,7 +11,6 @@ import Log from '../../log';
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
import { WorkflowFile } from '../../utils/workflowFile';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import { parsedYamlFromWorkflowContents } from './parse';
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
@@ -163,6 +162,11 @@ function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): vo
}
}
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
return parsedYaml;
}
export function workflowContentsFromParsedYaml(parsedYaml: any): string {
return YAML.stringify(parsedYaml);
}
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -202,12 +201,13 @@ function displayUsage(data: UsageDisplayData, usageData: AccountFullUsageData):
export default class AccountUsage extends EasCommand {
static override description = 'view account usage and billing for the current cycle';
static override args = {
ACCOUNT_NAME: Args.string({
static override args = [
{
name: 'ACCOUNT_NAME',
description:
'Account name to view usage for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
}),
};
},
];
static override flags = {
...EasJsonOnlyFlag,
+1 -6
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { getAnalyticsEnabledAsync, setAnalyticsEnabledAsync } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
@@ -6,11 +5,7 @@ import Log from '../log';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static override args = {
STATUS: Args.string({
options: ['on', 'off'] as const,
}),
};
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
@@ -13,12 +12,13 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BranchCreate extends EasCommand {
static override description = 'create a branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to create',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -61,9 +61,6 @@ export default class BranchCreate extends EasCommand {
initial: (await getDefaultBranchNameAsync(vcsClient)) ?? undefined,
validate: value => (value ? true : validationMessage),
}));
if (!name) {
throw new Error(validationMessage);
}
}
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, { appId: projectId, name });
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
@@ -55,12 +54,13 @@ export default class BranchDelete extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to delete',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
+5 -5
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
@@ -13,12 +12,13 @@ import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view a branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the branch to view',
}),
};
},
];
static override flags = {
...EasPaginatedQueryFlags,
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -93,17 +93,15 @@ export async function selectBuildToCancelAsync(
export default class BuildCancel extends EasCommand {
static override description = 'cancel a build';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.option({
platform: Flags.enum({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
})(),
}),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -142,7 +140,7 @@ export default class BuildCancel extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg ?? null;
let buildId: string | null = buildIdFromArg;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
@@ -19,11 +19,11 @@ export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
};
static override contextDefinition = {
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -79,16 +79,14 @@ export async function selectBuildToDeleteAsync(
export default class BuildDelete extends EasCommand {
static override description = 'delete a build';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.option({
platform: Flags.enum({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
})(),
}),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -123,7 +121,7 @@ export default class BuildDelete extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg ?? null;
let buildId: string | null = buildIdFromArg;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
+3 -3
View File
@@ -34,10 +34,10 @@ export default class BuildDev extends EasCommand {
'run dev client simulator/emulator build with matching fingerprint or create a new one';
static override flags = {
platform: Flags.option({
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
options: [Platform.IOS, Platform.ANDROID],
}),
profile: Flags.string({
char: 'e',
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
@@ -23,10 +23,10 @@ export default class Download extends EasCommand {
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.option({
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
options: [Platform.IOS, Platform.ANDROID],
}),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
+7 -7
View File
@@ -45,10 +45,10 @@ export default class Build extends EasCommand {
static override description = 'start a build';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
'skip-credentials-check': Flags.boolean({
default: false,
hidden: true,
@@ -96,7 +96,7 @@ export default class Build extends EasCommand {
description:
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
}),
'resource-class': Flags.option({
'resource-class': Flags.enum({
options: Object.values(ResourceClass),
hidden: true,
deprecated: {
@@ -105,15 +105,15 @@ export default class Build extends EasCommand {
),
},
description: 'The instance type that will be used to run this build [experimental]',
})(),
}),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
'build-logger-level': Flags.option({
'build-logger-level': Flags.enum({
description: 'The level of logs to output during the build process. Defaults to "info".',
options: Object.values(LoggerLevel),
})(),
}),
'freeze-credentials': Flags.boolean({
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
@@ -28,23 +28,23 @@ export default class BuildInspect extends EasCommand {
'inspect the state of the project at specific build stages, useful for troubleshooting';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: [RequestedPlatform.Android, RequestedPlatform.Ios] as const,
options: [RequestedPlatform.Android, RequestedPlatform.Ios],
required: true,
})(),
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
stage: Flags.option({
stage: Flags.enum({
char: 's',
description: STAGE_DESCRIPTION,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD] as const,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD],
required: true,
})(),
}),
output: Flags.string({
char: 'o',
description: 'Output directory.',
@@ -18,11 +18,11 @@ export default class BuildInternal extends EasCommand {
static override hidden = true;
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
options: ['android', 'ios'],
required: true,
})(),
}),
profile: Flags.string({
char: 'e',
description:
+6 -6
View File
@@ -20,18 +20,18 @@ export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
options: Object.values(RequestedPlatform),
char: 'p',
})(),
status: Flags.option({
}),
status: Flags.enum({
options: Object.values(BuildStatus),
description: 'Filter only builds with the specified status',
})(),
distribution: Flags.option({
}),
distribution: Flags.enum({
options: Object.values(BuildDistributionType),
description: 'Filter only builds with the specified distribution type',
})(),
}),
channel: Flags.string(),
'app-version': Flags.string({
aliases: ['appVersion'],
@@ -66,10 +66,10 @@ export default class BuildResign extends EasCommand {
static override description = 're-sign a build archive';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
'target-profile': Flags.string({
char: 'e',
description:
+3 -3
View File
@@ -62,10 +62,10 @@ export default class Run extends EasCommand {
description: 'ID of the simulator/emulator build to run',
exclusive: ['latest, path, url'],
}),
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -22,10 +22,10 @@ export default class BuildVersionGetView extends EasCommand {
public static override description = 'get the latest version from EAS servers';
public static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -26,10 +26,10 @@ export default class BuildVersionSetView extends EasCommand {
static override description = 'update version of an app';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
@@ -45,10 +45,10 @@ export default class BuildVersionSyncView extends EasCommand {
'update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'] as const,
})(),
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
+1 -4
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
@@ -12,9 +11,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
static override description = 'view a build for your project';
static override args = {
BUILD_ID: Args.string({}),
};
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
...EasJsonOnlyFlag,
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createAndLinkChannelAsync } from '../../channel/queries';
@@ -11,12 +10,13 @@ import { enableJsonOutput } from '../../utils/json';
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to create',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -53,9 +53,6 @@ export default class ChannelCreate extends EasCommand {
message: 'Provide a channel name:',
validate: value => (value ? true : validationMessage),
}));
if (!channelName) {
throw new Error(validationMessage);
}
}
await createAndLinkChannelAsync(graphqlClient, {
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
@@ -12,12 +11,13 @@ import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJ
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to delete',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -58,12 +58,13 @@ export async function updateChannelBranchMappingAsync(
export default class ChannelEdit extends EasCommand {
static override description = 'point a channel at a new branch';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -50,12 +50,13 @@ export async function pauseUpdateChannelAsync(
export default class ChannelPause extends EasCommand {
static override description = 'pause a channel to stop it from sending updates';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -50,12 +50,13 @@ export async function resumeUpdateChannelAsync(
export default class ChannelResume extends EasCommand {
static override description = 'resume a channel to start sending updates';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to edit',
}),
};
},
];
static override flags = {
branch: Flags.string({
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
@@ -49,14 +49,15 @@ type ChannelRolloutArgsAndFlags = {
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = {
channel: Args.string({
static override args = [
{
name: 'channel',
description: 'channel on which the rollout should be done',
}),
};
},
];
static override flags = {
action: Flags.option({
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
@@ -96,17 +97,17 @@ export default class ChannelRollout extends EasCommand {
],
},
],
})(),
}),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
outcome: Flags.option({
outcome: Flags.enum({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
})(),
}),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import assert from 'assert';
import {
@@ -13,12 +12,13 @@ import { enableJsonOutput } from '../../utils/json';
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
static override args = {
name: Args.string({
static override args = [
{
name: 'name',
required: false,
description: 'Name of the channel to view',
}),
};
},
];
static override flags = {
...EasNonInteractiveAndJsonFlags,
+1 -1
View File
@@ -17,7 +17,7 @@ export default class Config extends EasCommand {
static override description = 'display project configuration (app.json + eas.json)';
static override flags = {
platform: Flags.option({ char: 'p', options: [Platform.ANDROID, Platform.IOS] as const })(),
platform: Flags.enum<Platform>({ char: 'p', options: [Platform.ANDROID, Platform.IOS] }),
profile: Flags.string({
char: 'e',
description:
@@ -10,10 +10,10 @@ export default class InitializeBuildCredentials extends EasCommand {
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: [Platform.ANDROID, Platform.IOS] as const,
})(),
options: [Platform.ANDROID, Platform.IOS],
}),
profile: Flags.string({
char: 'e',
description: 'The name of the build profile in eas.json.',
@@ -7,7 +7,7 @@ export default class Credentials extends EasCommand {
static override description = 'manage credentials';
static override flags = {
platform: Flags.option({ char: 'p', options: ['android', 'ios'] as const })(),
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
};
static override contextDefinition = {
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
@@ -23,9 +22,7 @@ export default class WorkerAliasDelete extends EasCommand {
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = {
ALIAS_NAME: Args.string({}),
};
static override args = [{ name: 'ALIAS_NAME' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -24,9 +23,7 @@ export default class WorkerDelete extends EasCommand {
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = {
DEPLOYMENT_ID: Args.string({}),
};
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
+1 -4
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
@@ -9,9 +8,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
export default class DeviceView extends EasCommand {
static override description = 'view a device for your project';
static override args = {
UDID: Args.string({}),
};
static override args = [{ name: 'UDID' }];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
@@ -1,4 +1,6 @@
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -26,7 +28,7 @@ jest.mock('../../../utils/prompts');
describe(EnvCreate, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const variableId = 'testId';
const testAccountId = 'test-account-id';
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -20,7 +21,7 @@ describe(EnvDelete, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -16,7 +17,7 @@ jest.mock('../../../log');
describe(EnvExec, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockEnvironmentVariables = [
{
+3 -2
View File
@@ -1,4 +1,5 @@
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -20,7 +21,7 @@ jest.mock('../../../utils/prompts');
describe(EnvGet, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockVariables: EnvironmentVariableFragment[] = [
{
id: 'var1',
@@ -1,4 +1,6 @@
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -46,7 +48,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
describe(EnvList, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
beforeEach(() => {
jest.clearAllMocks();
@@ -1,7 +1,7 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import path from 'path';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -25,7 +25,7 @@ jest.mock('../../../log');
describe(EnvPull, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testTargetPath = '.env.local';
@@ -1,6 +1,6 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
@@ -23,7 +23,7 @@ jest.mock('../../../prompts');
describe(EnvPush, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testEnvPath = '.env.test';
@@ -1,7 +1,8 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
@@ -32,7 +33,7 @@ describe(EnvUpdate, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = getMockOclifConfig();
const mockConfig = {} as unknown as Config;
const mockContext = {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
+9 -8
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
@@ -59,13 +59,14 @@ interface CreateFlags {
export default class EnvCreate extends EasCommand {
static override description = 'create an environment variable for the current project or account';
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Environment to create the variable in. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
static override flags = {
name: Flags.string({
@@ -78,10 +79,10 @@ export default class EnvCreate extends EasCommand {
description: 'Overwrite existing variable',
default: false,
}),
type: Flags.option({
type: Flags.enum<'string' | 'file'>({
description: 'The type of variable',
options: ['string', 'file'] as const,
})(),
options: ['string', 'file'],
}),
...EASVariableVisibilityFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
+6 -5
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
@@ -45,13 +45,14 @@ export default class EnvDelete extends EasCommand {
...EASNonInteractiveFlag,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Current environment of the variable to delete. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
+8 -7
View File
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
@@ -38,17 +37,19 @@ export default class EnvExec extends EasCommand {
...EASNonInteractiveFlag,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
required: true,
description:
"Environment to execute the command in. Default environments are 'production', 'preview', and 'development'.",
}),
bash_command: Args.string({
},
{
name: 'bash_command',
required: true,
description: 'bash command to execute with the environment variables from the environment',
}),
};
},
];
private isNonInteractive: boolean = false;
+6 -5
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -44,13 +44,14 @@ export default class EnvGet extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Current environment of the variable. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
static override flags = {
'variable-name': Flags.string({
+6 -5
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -106,13 +106,14 @@ export default class EnvList extends EasCommand {
...EASEnvironmentVariableScopeFlag,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Environment to list the variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvList);
+6 -5
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
@@ -24,13 +24,14 @@ export default class EnvPull extends EasCommand {
...this.ContextOptions.ProjectDir,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
static override flags = {
...EASNonInteractiveFlag,
+7 -6
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
@@ -38,13 +38,14 @@ export default class EnvPush extends EasCommand {
}),
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Environment to push variables to. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvPush);
@@ -213,7 +214,7 @@ export default class EnvPush extends EasCommand {
environment: string[] | undefined;
force: boolean;
},
{ environment }: { environment?: string }
{ environment }: Record<string, string>
): { environment?: string[]; path: string; force: boolean } {
const environments = flags.environment ?? (environment ? [environment] : undefined);
+9 -8
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -79,23 +79,24 @@ export default class EnvUpdate extends EasCommand {
value: Flags.string({
description: 'New value or the variable',
}),
type: Flags.option({
type: Flags.enum<'string' | 'file'>({
description: 'The type of variable',
options: ['string', 'file'] as const,
})(),
options: ['string', 'file'],
}),
...EASVariableVisibilityFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = {
environment: Args.string({
static override args = [
{
name: 'environment',
description:
"Current environment of the variable to update. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';
@@ -68,17 +68,19 @@ export default class FingerprintCompare extends EasCommand {
'$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> \t # Compare fingerprint from update against provided fingerprint',
];
static override args = {
hash1: Args.string({
static override args = [
{
name: 'hash1',
description:
"If provided alone, HASH1 is compared against the current project's fingerprint.",
required: false,
}),
hash2: Args.string({
},
{
name: 'hash2',
description: 'If two hashes are provided, HASH1 is compared against HASH2.',
required: false,
}),
};
},
];
static override flags = {
'build-id': Flags.string({
@@ -153,8 +155,8 @@ export default class FingerprintCompare extends EasCommand {
);
const { fingerprint: firstFingerprint, origin: firstFingerprintOrigin } = firstFingerprintInfo;
const isFirstFingerprintSpecifiedByFlagOrArg = Boolean(hash1 || buildId1 || updateId1);
const isSecondFingerprintSpecifiedByFlagOrArg = Boolean(hash2 || buildId2 || updateId2);
const isFirstFingerprintSpecifiedByFlagOrArg = hash1 || buildId1 || updateId1;
const isSecondFingerprintSpecifiedByFlagOrArg = hash2 || buildId2 || updateId2;
const secondFingerprintInfo = await getFingerprintInfoAsync(
graphqlClient,
projectDir,
@@ -285,7 +287,7 @@ async function getFingerprintInfoAsync(
}: {
buildId?: string;
updateId?: string;
hash?: string;
hash: string;
useProjectFingerprint?: boolean;
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
@@ -34,10 +34,10 @@ export default class FingerprintGenerate extends EasCommand {
];
static override flags = {
platform: Flags.option({
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'] as const,
})(),
options: ['android', 'ios'],
}),
...EASEnvironmentFlag,
environment: Flags.string({
...EasEnvironmentFlagParameters,
+5 -50
View File
@@ -173,19 +173,20 @@ 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/deploy-expo-go-testflight')}`
`Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/personal-expo-go')}`
)
);
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,
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const appName = flags.name ?? 'My Expo Go';
@@ -550,34 +551,12 @@ export default class Go extends EasCommand {
graphqlClient: ExpoGraphqlClient,
workflowRunId: string
): Promise<WorkflowRunStatus> {
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();
const buildSpinner = ora('Building Expo Go').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,
@@ -601,14 +580,7 @@ export default class Go extends EasCommand {
}
if (buildCompleted && submitSpinner === null && submitJob) {
submitStartTime = Date.now();
submitSpinner = ora(
this.formatSpinnerText(
'Submitting to TestFlight',
EXPECTED_SUBMIT_DURATION_SECONDS,
submitStartTime
)
).start();
submitSpinner = ora('Submitting to TestFlight').start();
}
if (workflowRun.status === WorkflowRunStatus.Success) {
@@ -635,21 +607,4 @@ 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)`;
}
}
@@ -0,0 +1,237 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppObservePlatform } from '../../../graphql/generated';
import { fetchObserveEventsAsync } from '../../../observe/fetchEvents';
import { buildObserveEventsJson } from '../../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveEvents from '../events';
jest.mock('../../../observe/fetchEvents');
jest.mock('../../../observe/formatEvents', () => ({
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
buildObserveEventsJson: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveEvents, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveEventsAsync.mockResolvedValue({
events: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
});
function createCommand(argv: string[]): ObserveEvents {
const command = new ObserveEvents(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--days-from-now', '7']);
await command.runAsync();
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--metric',
'tti',
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('defaults endTime to now when only --start is provided', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--start',
'2025-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --limit to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--limit', '42']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.limit).toBe(42);
});
it('passes --after cursor to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.after).toBe('cursor-xyz');
});
it('does not pass after when --after flag is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options).not.toHaveProperty('after');
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand([
'--metric',
'tti',
'--days-from-now',
'7',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'ios']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Ios);
});
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
const command = createCommand(['--metric', 'tti', '--platform', 'android']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBe(AppObservePlatform.Android);
});
it('passes --app-version to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.appVersion).toBe('2.1.0');
});
it('passes --update-id to fetchObserveEventsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.updateId).toBe('update-xyz');
});
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
expect(options.platform).toBeUndefined();
expect(options.appVersion).toBeUndefined();
expect(options.updateId).toBeUndefined();
});
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
const mockEvents = [
{
id: 'evt-1',
metricName: 'expo.app_startup.tti',
metricValue: 1.23,
timestamp: '2025-01-15T10:30:00.000Z',
appVersion: '1.0.0',
appBuildNumber: '42',
deviceModel: 'iPhone 15',
deviceOs: 'iOS',
deviceOsVersion: '17.0',
countryCode: 'US',
sessionId: 'session-1',
easClientId: 'client-1',
},
];
mockFetchObserveEventsAsync.mockResolvedValue({
events: mockEvents as any,
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
mockEvents,
expect.objectContaining({ hasNextPage: false })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
it('does not call enableJsonOutput when --json is not provided', async () => {
const command = createCommand(['--metric', 'tti']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,214 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../graphql/generated';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveMetrics from '../metrics';
jest.mock('../../../observe/fetchMetrics', () => {
const actual = jest.requireActual('../../../observe/fetchMetrics');
return {
...actual,
fetchObserveMetricsAsync: jest.fn(),
};
});
jest.mock('../../../observe/formatMetrics', () => ({
...jest.requireActual('../../../observe/formatMetrics'),
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveMetrics, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveMetricsAsync.mockResolvedValue(new Map());
});
function createCommand(argv: string[]): ObserveMetrics {
const command = new ObserveMetrics(argv, mockConfig);
// @ts-expect-error getContextAsync is a protected method
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('fetches metrics with default parameters (both platforms)', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
jest.useRealTimers();
});
it('queries only Android when --platform android is passed', async () => {
const command = createCommand(['--platform', 'android']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Android]);
});
it('queries only iOS when --platform ios is passed', async () => {
const command = createCommand(['--platform', 'ios']);
await command.runAsync();
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
expect(platforms).toEqual([AppPlatform.Ios]);
});
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
await command.runAsync();
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
});
it('uses default time range (60 days back) when no --start/--end flags', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand([]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
jest.useRealTimers();
});
it('uses explicit --start and --end when provided', async () => {
const command = createCommand([
'--start',
'2025-01-01T00:00:00.000Z',
'--end',
'2025-02-01T00:00:00.000Z',
]);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
});
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
const command = createCommand(['--stat', 'p90', '--stat', 'count']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['p90', 'eventCount']
);
});
it('deduplicates --stat flags that resolve to the same key', async () => {
const command = createCommand(['--stat', 'med', '--stat', 'median']);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median']
);
});
it('uses --days-from-now to compute start/end time range', async () => {
const now = new Date('2025-06-15T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--days-from-now', '7']);
await command.runAsync();
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
jest.useRealTimers();
});
it('rejects --days-from-now combined with --start', async () => {
const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('rejects --days-from-now combined with --end', async () => {
const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']);
await expect(command.runAsync()).rejects.toThrow();
});
it('uses default stats when --stat is not provided', async () => {
const command = createCommand([]);
await command.runAsync();
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['median', 'eventCount']
);
});
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
const command = createCommand([
'--json',
'--non-interactive',
'--stat',
'min',
'--stat',
'avg',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
expect.any(Map),
expect.any(Array),
['min', 'average']
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
});
});
describe(validateDateFlag, () => {
it('throws on invalid --start date', () => {
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
'Invalid --start date: "not-a-date"'
);
});
it('throws on invalid --end date', () => {
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
});
it('accepts valid ISO date in --start', () => {
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
});
});
@@ -0,0 +1,133 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { AppObservePlatform } from '../../graphql/generated';
import Log from '../../log';
import {
type EventsOrderPreset,
fetchObserveEventsAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { resolveMetricName } from '../../observe/metricNames';
import { validateDateFlag } from '../../observe/fetchMetrics';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_EVENTS_LIMIT = 10;
const DEFAULT_DAYS_BACK = 60;
export default class ObserveEvents extends EasCommand {
static override description = 'display individual app performance events ordered by metric value';
static override flags = {
metric: Flags.string({
description:
'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)',
required: true,
}),
sort: Flags.enum<EventsOrderPreset>({
description: 'Sort order for events',
options: ['slowest', 'fastest', 'newest', 'oldest'],
default: 'slowest',
}),
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_EVENTS_LIMIT, limit: 100 }),
start: Flags.string({
description: 'Start of time range (ISO date)',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range (ISO date)',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
'app-version': Flags.string({
description: 'Filter by app version',
}),
'update-id': Flags.string({
description: 'Filter by EAS update ID',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveEvents);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricName = resolveMetricName(flags.metric);
const orderBy = resolveOrderBy(flags.sort);
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platform = flags.platform
? flags.platform === 'android'
? AppObservePlatform.Android
: AppObservePlatform.Ios
: undefined;
const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
...(flags.after && { after: flags.after }),
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
});
if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventsTable(events, pageInfo));
}
}
}
@@ -0,0 +1,142 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform } from '../../graphql/generated';
import Log from '../../log';
import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics';
import {
StatisticKey,
buildObserveMetricsJson,
buildObserveMetricsTable,
resolveStatKey,
} from '../../observe/formatMetrics';
import { resolveMetricName } from '../../observe/metricNames';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_METRICS = [
'expo.app_startup.cold_launch_time',
'expo.app_startup.warm_launch_time',
'expo.app_startup.tti',
'expo.app_startup.ttr',
'expo.app_startup.bundle_load_time',
];
const DEFAULT_DAYS_BACK = 60;
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
const DEFAULT_STATS_JSON: StatisticKey[] = [
'min',
'median',
'max',
'average',
'p80',
'p90',
'p99',
'eventCount',
];
export default class ObserveMetrics extends EasCommand {
static override description = 'display app performance metrics grouped by app version';
static override flags = {
platform: Flags.enum<'android' | 'ios'>({
description: 'Filter by platform',
options: ['android', 'ios'],
}),
metric: Flags.string({
description:
'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load',
multiple: true,
}),
stat: Flags.string({
description:
'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count',
multiple: true,
}),
start: Flags.string({
description: 'Start of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
end: Flags.string({
description: 'End of time range for metrics data (ISO date).',
exclusive: ['days-from-now'],
}),
'days-from-now': Flags.integer({
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
min: 1,
exclusive: ['start', 'end'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
if (flags.json) {
enableJsonOutput();
} else {
Log.warn('EAS Observe is in preview and subject to breaking changes.');
}
if (flags.start) {
validateDateFlag(flags.start, '--start');
}
if (flags.end) {
validateDateFlag(flags.end, '--end');
}
const metricNames = flags.metric?.length
? flags.metric.map(resolveMetricName)
: DEFAULT_METRICS;
let startTime: string;
let endTime: string;
if (flags['days-from-now']) {
endTime = new Date().toISOString();
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
} else {
endTime = flags.end ?? new Date().toISOString();
startTime =
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
}
const platforms: AppPlatform[] = flags.platform
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
: [AppPlatform.Android, AppPlatform.Ios];
const metricsMap = await fetchObserveMetricsAsync(
graphqlClient,
projectId,
metricNames,
platforms,
startTime,
endTime
);
const argumentsStat = flags.stat?.length
? Array.from(new Set(flags.stat.map(resolveStatKey)))
: undefined;
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats));
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
Log.addNewLineIfNone();
Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats));
}
}
}
@@ -3,7 +3,6 @@ import chalk from 'chalk';
import { vol } from 'memfs';
import { instance, mock } from 'ts-mockito';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
import ProjectDirContextField from '../../../commandUtils/context/ProjectDirContextField';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -98,7 +97,7 @@ function mockTestProject(options: {
jest.mocked(isExpoInstalled).mockReturnValue(true);
}
const commandOptions = getMockOclifConfig('/test-project');
const commandOptions = { root: '/test-project' } as any;
beforeEach(() => {
jest.resetAllMocks();
@@ -167,10 +166,9 @@ describe(ProjectInit.name, () => {
});
it('does not prompt to overwrite when different', async () => {
await new ProjectInit(
['--id', '12345', '--force'],
getMockOclifConfig('/test-project')
).run();
await new ProjectInit(['--id', '12345', '--force'], {
root: '/test-project',
} as any).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(confirmAsync).not.toHaveBeenCalled();
});
@@ -179,10 +177,9 @@ describe(ProjectInit.name, () => {
describe('non-interactive', () => {
it('aborts when different', async () => {
await expect(
new ProjectInit(
['--id', '12345', '--non-interactive'],
getMockOclifConfig('/test-project')
).run()
new ProjectInit(['--id', '12345', '--non-interactive'], {
root: '/test-project',
} as any).run()
).rejects.toThrowError(
`Project is already linked to a different ID: ${chalk.bold(
'1234'
+9 -8
View File
@@ -1,4 +1,4 @@
import { Args, Flags } from '@oclif/core';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
@@ -132,20 +132,21 @@ export default class New extends EasCommand {
static override description =
'Create a new project configured with Expo Application Services (EAS)';
static override args = {
path: Args.string({
static override args = [
{
name: 'path',
description: 'Path to create the project (defaults to current directory)',
required: false,
}),
};
},
];
static override flags = {
'package-manager': Flags.option({
'package-manager': Flags.enum<PackageManager>({
char: 'p',
description: 'Package manager to use for installing dependencies',
options: [...PACKAGE_MANAGERS] as const,
options: [...PACKAGE_MANAGERS],
default: 'npm',
})(),
}),
};
static override contextDefinition = {
@@ -1,4 +1,3 @@
import { Args } from '@oclif/core';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { AppVersionSource, EasJson } from '@expo/eas-json';
@@ -46,9 +45,7 @@ export default class Onboarding extends EasCommand {
static override flags = {};
static override args = {
TARGET_PROJECT_DIRECTORY: Args.string({}),
};
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
@@ -28,21 +28,21 @@ export default class EnvironmentSecretCreate extends EasCommand {
static override hidden = true;
static override flags = {
scope: Flags.option({
scope: Flags.enum({
description: 'Scope for the secret',
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT] as const,
options: [EnvironmentSecretScope.ACCOUNT, EnvironmentSecretScope.PROJECT],
default: EnvironmentSecretScope.PROJECT,
})(),
}),
name: Flags.string({
description: 'Name of the secret',
}),
value: Flags.string({
description: 'Text value or path to a file to store in the secret',
}),
type: Flags.option({
type: Flags.enum({
description: 'The type of secret',
options: [SecretType.STRING, SecretType.FILE] as const,
})(),
options: [SecretType.STRING, SecretType.FILE],
}),
force: Flags.boolean({
description: 'Delete and recreate existing secrets',
default: false,
@@ -171,7 +171,6 @@ export default class EnvironmentSecretCreate extends EasCommand {
}
assert(secretValue);
const environmentSecretType = SecretTypeToEnvironmentSecretType[secretType as SecretType];
let secretFilePath: string | undefined;
if (secretType === SecretType.FILE) {
@@ -202,7 +201,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
const secret = await EnvironmentSecretMutation.createForAppAsync(
graphqlClient,
{ name, value: secretValue, type: environmentSecretType },
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
projectId
);
if (!secret) {
@@ -245,7 +244,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
const secret = await EnvironmentSecretMutation.createForAccountAsync(
graphqlClient,
{ name, value: secretValue, type: environmentSecretType },
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
ownerAccount.id
);
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more