Compare commits

...
7 Commits
Author SHA1 Message Date
Expo CI 39c39ad4bf v22.4.0 2026-08-24 21:35:36 +00:00
Szymon Dziedzic 1ad3822421 [eas-cli] Add simulator launch options (#4264)
* [eas-cli] add simulator launch options

* [eas-cli] add simulator launch options changelog

* [eas-cli] resolve simulator Expo Go archives server-side

* [eas-cli] rename simulator open URL flag

* [eas-cli] rename simulator open URL GraphQL field
2026-08-24 23:33:13 +02:00
Szymon Dziedzic 0929787398 [build-tools] Support simulator application launch options (#4263)
* [build-tools] support simulator application launch options

* [build-tools] add simulator launch options changelog

* [build-tools] rename launch application open URL input

* [build-tools] clarify simulator launch arguments
2026-08-24 23:19:30 +02:00
Szymon Dziedzic 756016a433 [build-tools] Support flat app bundle archives (#4262)
* [build-tools] Support flat app bundle archives

* [build-tools] Add flat app archive changelog
2026-08-24 23:07:07 +02:00
Jon Samp c6e3c332ac [eas-cli] Add eas billing:subscribe and billing:manage commands (#4007) 2026-08-24 14:44:44 -05:00
Stanisław Chmiela 7c95a6b4b2 [build-tools] Restore maestro-runner WDA cache (#4195)
## Why

`maestro-runner` needs WDA driver to run tests. By default it compiles it on first run which takes a minute.

## How

Compiled WDA on all images, uploaded to `turtle-v2` GCS and added optimistic download to `eas/install_maestro`. See https://github.com/expo/turtle-v2/pull/2603.

## Test plan

https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/01a0009e-b171-7497-9335-af1fc470a8d5
2026-08-24 21:32:24 +02:00
Szymon Świerk 16d234b11d [steps] Report a broken composite if: on the call, not an inner step (#4216)
* [steps] Report a broken composite if: on the call, not an inner step

* [steps] Simplify condition evaluation error handling by building the erro message in the error constructor
2026-08-24 18:52:39 +02:00
53 changed files with 2726 additions and 369 deletions

No files matched your search

+5
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
@@ -15,6 +17,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
@@ -23,6 +27,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.3.0",
"version": "22.4.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.3.0",
"version": "22.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -46,7 +46,7 @@
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.1.0",
"@expo/steps": "22.4.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -0,0 +1,46 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { getXcodeVersionAsync } from '../xcode';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(getXcodeVersionAsync, () => {
it('returns the Xcode version', async () => {
mockedSpawn.mockResolvedValue({
stdout: 'Xcode 26.0\nBuild version 17A324\n',
} as any);
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
stdio: 'pipe',
env: process.env,
});
});
it('throws when the Xcode version cannot be parsed', async () => {
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
'Failed to determine Xcode version'
);
});
it('wraps errors from xcodebuild in a SystemError', async () => {
const cause = new Error('xcode-select: error: invalid developer directory');
mockedSpawn.mockRejectedValue(cause);
const promise = getXcodeVersionAsync({ env: process.env });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: 'Failed to get Xcode version',
cause,
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
let stdout: string;
try {
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
} catch (error) {
throw new SystemError('Failed to get Xcode version', { cause: error });
}
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
if (!version) {
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
}
return version;
}
@@ -4,8 +4,10 @@ import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -19,6 +21,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const FLAT_APP_INFO_PLIST_BASE64 =
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
@@ -74,6 +79,34 @@ function createSuccessfulResponse({
} as unknown as Response;
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
try {
await Promise.all([
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
encoding: 'base64',
}),
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
]);
await tar.create(
{
cwd: sourceDirectory,
file: archivePath,
gzip: true,
},
['Info.plist', 'TestApp']
);
return await fs.promises.readFile(archivePath);
} finally {
await Promise.all([
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
fs.promises.rm(archivePath, { force: true }),
]);
}
}
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -106,6 +139,28 @@ describe('downloadBuild', () => {
);
});
it('downloads an archive containing app bundle contents at its root', async () => {
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: await createFlatAppTarGzBufferAsync(),
url: APPLICATION_ARCHIVE_URL,
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
expect(path.extname(artifactPath)).toBe('.app');
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
);
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
@@ -1,6 +1,10 @@
import downloadFile from '@expo/downloader';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { BuildRuntimePlatform } from '@expo/steps';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
@@ -11,6 +15,11 @@ jest.mock('@expo/turtle-spawn', () => ({
default: jest.fn(),
}));
jest.mock('@expo/downloader', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
@@ -18,10 +27,24 @@ jest.mock('../../../datadog', () => ({
}));
const mockedSpawn = jest.mocked(spawn);
const mockedDownloadFile = jest.mocked(downloadFile);
async function writeInstalledWdaVersion(homeDirectory: string, version: string): Promise<void> {
const wdaDirectory = path.join(
homeDirectory,
'.maestro-runner',
'drivers',
'ios',
'WebDriverAgent'
);
await fs.promises.mkdir(wdaDirectory, { recursive: true });
await fs.promises.writeFile(path.join(wdaDirectory, 'package.json'), JSON.stringify({ version }));
}
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedDownloadFile.mockResolvedValue(undefined);
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
@@ -439,4 +462,184 @@ describe('createInstallMaestroBuildFunction', () => {
process.env.PATH = originalPath;
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
// The installed WDA version is unknown (no package.json under $HOME), so the
// cache download is skipped before resolving the Xcode/runtime versions.
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
});
it('continues when the prebuilt WDA cache is not available', async () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
isAvailable: true,
version: '26.5',
},
],
}),
};
default:
return { stdout: '' };
}
}) as any);
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
]);
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
});
@@ -35,6 +35,32 @@ describe(launchApplicationAsync, () => {
);
});
it('passes launch arguments and opens a URL on iOS', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'host.exp.Exponent',
launchArgs: ['--uitesting', 'true'],
openUrl: 'exp://example.test',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'xcrun',
['simctl', 'launch', 'booted', 'host.exp.Exponent', '--uitesting', 'true'],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xcrun',
['simctl', 'openurl', 'booted', 'exp://example.test'],
{ env: {}, logger }
);
});
it('launches an Android Emulator application by package and activity', async () => {
const logger = createMockLogger();
@@ -53,6 +79,56 @@ describe(launchApplicationAsync, () => {
);
});
it('passes launch arguments and opens a URL on Android', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'host.exp.exponent',
activityName: 'host.exp.exponent.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
openUrl: 'exp://example.test',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'--ez',
'isTest',
'true',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
);
});
it('requires an activity when launching an Android application', async () => {
await expect(
launchApplicationAsync({
@@ -74,6 +150,8 @@ describe(launchApplicationAsync, () => {
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--ez', 'isTest', 'true'],
open_url: 'exp://example.test',
},
}
);
@@ -81,9 +159,71 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
[
'shell',
'am',
'start',
'--ez',
'isTest',
'true',
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
});
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
{
callInputs: {
application_identifier: 'com.example.app',
launch_args: launchArgs,
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: 'Input "launch_args" must be an array of strings.',
});
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects an invalid URL to open', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
{
callInputs: {
application_identifier: 'com.example.app',
open_url: 'not a URL',
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: 'Input "open_url" must be a valid URL.',
});
expect(mockedSpawn).not.toHaveBeenCalled();
});
it.each(['application_identifier', 'activity_name'])(
@@ -19,6 +19,8 @@ import path from 'node:path';
import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import bplistParser from 'bplist-parser';
import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
@@ -226,6 +228,18 @@ export async function downloadBuildAsync(
onlyFiles: false,
onlyDirectories: false,
});
let matchingFilesRoot = extractionDirectory;
if (
matchingFiles.length === 0 &&
extensions.includes('app') &&
(await isIosAppBundleAsync(extractionDirectory))
) {
const appBundlePath = `${extractionDirectory}.app`;
await fs.promises.rename(extractionDirectory, appBundlePath);
matchingFiles.push(appBundlePath);
matchingFilesRoot = path.dirname(appBundlePath);
}
if (matchingFiles.length === 0) {
throw new UserError(
@@ -238,12 +252,28 @@ export async function downloadBuildAsync(
`Found ${matchingFiles.length} matching ${pluralize(
matchingFiles.length,
'entry'
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
);
return { artifactPath: matchingFiles[0] };
}
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
try {
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
const parsedInfoPlist = (
isBinaryPlist
? bplistParser.parseBuffer(infoPlist)[0]
: plist.parse(infoPlist.toString('utf8'))
) as Record<string, unknown> | undefined;
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
} catch {
return false;
}
}
function parseHttpApplicationArchiveUrl(value: unknown): string {
try {
const applicationArchiveUrl = z.string().parse(value);
@@ -1,3 +1,4 @@
import downloadFile from '@expo/downloader';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
@@ -19,6 +20,11 @@ import semver from 'semver';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { Datadog } from '../../datadog';
import { getXcodeVersionAsync } from '../../ios/xcode';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { getProxiedDownloadUrl } from '../../utils/download';
const MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS = 20_000;
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -148,11 +154,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
(requestedVersion === undefined &&
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
) {
const xcodeVersion = await getXcodeVersion({ env });
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
// below 26 reject that option, so use the last compatible maestro-runner version.
if (semver.lt(xcodeVersion, '26.0.0')) {
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
if (requestedMaestroRunnerVersion) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
@@ -190,6 +198,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
throw new Error(`Failed to ensure ${backend} is installed.`);
}
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
await installMaestroRunnerWdaCache({
logger,
env,
});
}
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
@@ -201,19 +216,140 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
});
}
async function getXcodeVersion({ env }: { env: BuildStepEnv }): Promise<string> {
let stdout: string;
try {
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
} catch (error) {
throw new SystemError('Failed to get Xcode version', { cause: error });
async function installMaestroRunnerWdaCache({
logger,
env,
}: {
logger: bunyan;
env: BuildStepEnv;
}): Promise<void> {
if (!env.HOME) {
logger.warn(
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
);
return;
}
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
const xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
if (!xcodeVersion) {
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
try {
const wdaVersion = await getMaestroRunnerWdaVersion({ maestroRunnerHome });
if (!wdaVersion) {
logger.info(
'Skipping the prebuilt WebDriverAgent cache because the installed WDA version is unknown.'
);
return;
}
const xcodeVersion = await getXcodeVersionAsync({ env });
const iosRuntimes = await IosSimulatorUtils.getAvailableRuntimesAsync({ env });
const iosRuntimeIdentifierPrefix = 'com.apple.CoreSimulator.SimRuntime.iOS-';
// maestro-runner derives its WDA cache key from the runtime identifier, not from the runtime
// version. These values can differ for patched runtimes, for example the iOS-26-3 identifier
// can report version 26.3.1.
const iosRuntimeVersions = [
...new Set(
iosRuntimes.map(runtime =>
runtime.identifier.slice(iosRuntimeIdentifierPrefix.length).replaceAll('-', '.')
)
),
];
if (iosRuntimeVersions.length === 0) {
logger.info(
'Skipping the prebuilt WebDriverAgent cache because no iOS runtime is available.'
);
return;
}
const tempDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install_maestro_runner_wda_cache')
);
try {
const archivePath = path.join(tempDirectory, 'wda-cache.tar.gz');
const directArchiveUrl =
`https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/` +
`xcode-${encodeURIComponent(xcodeVersion)}-wda-${encodeURIComponent(wdaVersion)}.tar.gz`;
const proxiedArchiveUrl = getProxiedDownloadUrl({
directUrl: directArchiveUrl,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
logger.info(`Downloading the prebuilt WebDriverAgent cache for Xcode ${xcodeVersion}`);
if (proxiedArchiveUrl) {
try {
await downloadFile(proxiedArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
} catch (err) {
logger.debug(
{ err },
'Failed to download the prebuilt WebDriverAgent cache via the proxy; falling back to the direct URL.'
);
await downloadFile(directArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
}
} else {
await downloadFile(directArchiveUrl, archivePath, {
retry: 3,
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
});
}
await fs.promises.mkdir(maestroRunnerHome, { recursive: true });
await spawn('tar', ['-xzf', archivePath, '-C', maestroRunnerHome], { logger, env });
const genericProductsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
for (const runtimeVersion of iosRuntimeVersions) {
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products'
);
await fs.promises.cp(genericProductsDirectory, productsDirectory, { recursive: true });
}
logger.info(
`Installed the prebuilt WebDriverAgent cache for iOS ${iosRuntimeVersions.join(', ')}.`
);
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
} catch (err: any) {
logger.warn(
{ err },
'Failed to install the prebuilt WebDriverAgent cache. maestro-runner will build WebDriverAgent when it is needed.'
);
}
}
async function getMaestroRunnerWdaVersion({
maestroRunnerHome,
}: {
maestroRunnerHome: string;
}): Promise<string | null> {
try {
const packageJson = JSON.parse(
await fs.promises.readFile(
path.join(maestroRunnerHome, 'drivers', 'ios', 'WebDriverAgent', 'package.json'),
'utf8'
)
);
return typeof packageJson.version === 'string' ? packageJson.version : null;
} catch {
return null;
}
return xcodeVersion;
}
async function getMaestroVersion({
@@ -26,6 +26,16 @@ export function createLaunchApplicationFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ global, logger }, { inputs, env }) => {
const applicationIdentifier = parseNonEmptyStringInput(
@@ -36,9 +46,14 @@ export function createLaunchApplicationFunction(): BuildFunction {
inputs.activity_name.value === undefined
? undefined
: parseNonEmptyStringInput(inputs.activity_name.value, 'activity_name');
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform: global.runtimePlatform,
env,
logger,
@@ -50,22 +65,30 @@ export function createLaunchApplicationFunction(): BuildFunction {
export async function launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs = [],
openUrl,
runtimePlatform,
env,
logger,
}: {
applicationIdentifier: string;
activityName?: string;
launchArgs?: string[];
openUrl?: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier], {
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier, ...launchArgs], {
env,
logger,
});
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn('xcrun', ['simctl', 'openurl', 'booted', openUrl], { env, logger });
}
return;
}
@@ -76,11 +99,45 @@ export async function launchApplicationAsync({
);
}
logger.info(`Launching ${applicationIdentifier}.`);
await spawn('adb', ['shell', 'am', 'start', '-n', `${applicationIdentifier}/${activityName}`], {
env,
logger,
});
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
}
);
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
openUrl,
'-n',
`${applicationIdentifier}/${activityName}`,
],
{ env, logger }
);
}
}
function logApplicationLaunch(
logger: bunyan,
applicationIdentifier: string,
launchArgs: string[]
): void {
const argumentsDescription =
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
}
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
@@ -92,3 +149,27 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
}
return value;
}
function parseLaunchArgsInput(value: unknown): string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value) || !value.every(argument => typeof argument === 'string')) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Input "launch_args" must be an array of strings.'
);
}
return value;
}
function parseOpenUrlInput(value: unknown): string {
const openUrl = parseNonEmptyStringInput(value, 'open_url');
if (!URL.canParse(openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Input "open_url" must be a valid URL.'
);
}
return openUrl;
}
@@ -9,6 +9,7 @@ import path from 'path';
import { z } from 'zod';
import { Sentry } from '../../../sentry';
import { getProxiedDownloadUrl } from '../../../utils/download';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
@@ -294,24 +295,6 @@ function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
@@ -44,6 +44,32 @@ export namespace IosSimulatorUtils {
};
};
export type IosSimulatorRuntime = {
identifier: string;
isAvailable?: boolean;
version?: string;
};
type XcrunSimctlListRuntimesJsonOutput = {
runtimes: (Omit<IosSimulatorRuntime, 'identifier'> & { identifier?: string })[];
};
export async function getAvailableRuntimesAsync({
env,
}: {
env: NodeJS.ProcessEnv;
}): Promise<IosSimulatorRuntime[]> {
const { stdout } = await spawn('xcrun', ['simctl', 'list', 'runtimes', '--json'], { env });
const { runtimes } = JSON.parse(stdout) as XcrunSimctlListRuntimesJsonOutput;
return runtimes.flatMap(runtime =>
runtime.isAvailable !== false &&
runtime.identifier?.startsWith('com.apple.CoreSimulator.SimRuntime.iOS-')
? [{ ...runtime, identifier: runtime.identifier }]
: []
);
}
export async function getAvailableDevicesAsync({
env,
filter,
@@ -23,6 +23,41 @@ describe('IosSimulatorUtils', () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
it('returns available iOS runtimes', async () => {
mockedSpawn.mockResolvedValue({
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
stderr: '',
} as any);
await expect(
IosSimulatorUtils.getAvailableRuntimesAsync({ env: process.env })
).resolves.toEqual([
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
]);
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'runtimes', '--json'], {
env: process.env,
});
});
});
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
it('adds a human-readable runtime display name', async () => {
mockedSpawn.mockResolvedValue({
@@ -0,0 +1,27 @@
import { getProxiedDownloadUrl } from '../download';
describe(getProxiedDownloadUrl, () => {
it('returns null when no proxy base URL is set', () => {
expect(
getProxiedDownloadUrl({ directUrl: 'https://storage.googleapis.com/bucket/object' })
).toBeNull();
});
it('rewrites the origin to the proxy base URL', () => {
expect(
getProxiedDownloadUrl({
directUrl: 'https://storage.googleapis.com/bucket/object',
proxyBaseUrl: 'https://cache.example.com',
})
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
});
it('normalizes a proxy base URL with a trailing slash', () => {
expect(
getProxiedDownloadUrl({
directUrl: 'https://storage.googleapis.com/bucket/object',
proxyBaseUrl: 'https://cache.example.com/',
})
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
});
});
@@ -0,0 +1,17 @@
export function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "22.2.0",
"version": "22.4.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "22.1.0"
"@expo/steps": "22.4.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+196 -130
View File
File diff suppressed because it is too large. Load diff
+58 -2
View File
@@ -37213,7 +37213,7 @@
},
{
"name": "applicationArchiveUrl",
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId.",
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId and expoGo.",
"type": {
"kind": "SCALAR",
"name": "String",
@@ -37225,7 +37225,7 @@
},
{
"name": "buildId",
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl.",
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl and expoGo.",
"type": {
"kind": "SCALAR",
"name": "ID",
@@ -37247,6 +37247,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "expoGo",
"description": "Install and launch Expo Go before the simulator session becomes available. The server resolves\nthe platform-specific application archive. Mutually exclusive with buildId and\napplicationArchiveUrl.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "launchArgs",
"description": "Arguments passed to the installed application when it is launched. Requires buildId,\napplicationArchiveUrl, or expoGo.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "maxIdleTimeMinutes",
"description": "Stop the session automatically after this many minutes without observed\nsession activity. Must be positive and smaller than the session's maximum\nduration (2 hours, or maxRunTimeMinutes when set). Only supported for\nagent-device, argent, and Appium sessions. If omitted, the session has no idle\ntimeout.",
@@ -37283,6 +37315,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "openUrl",
"description": "Expo or development-client URL to open after launching the installed application. Requires\nbuildId, applicationArchiveUrl, or expoGo.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "packageVersion",
"description": "The version of the package backing the device run session (e.g. \"0.1.3-alpha.3\").\nIf omitted, consumers treat the session as pinned to \"latest\".",
@@ -37311,6 +37355,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sdkVersion",
"description": "Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is\ntrue. If omitted, the current Expo Go archive for the platform is used.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.3.0",
"version": "22.4.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.1.0",
"@expo/steps": "22.4.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -110,6 +110,7 @@
"node-forge": "1.4.0",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"open": "8.4.0",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"pngjs": "7.0.0",
@@ -189,6 +190,9 @@
"account": {
"description": "manage account"
},
"billing": {
"description": "manage account billing and subscriptions"
},
"branch": {
"description": "manage update branches"
},
@@ -15,12 +15,21 @@ const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> =
linux: 'Linux',
};
export type AnalyticsEvent = CommandEvent | BuildEvent | SubmissionEvent | MetadataEvent;
export type AnalyticsEvent =
| CommandEvent
| BuildEvent
| SubmissionEvent
| MetadataEvent
| BillingEvent;
export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
export enum BillingEvent {
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
}
export enum SubmissionEvent {
SUBMIT_COMMAND = 'submit cli submit command',
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
@@ -0,0 +1,49 @@
import { ApiV2Client } from '../../api';
import { BillingClient } from '../billingClient';
jest.mock('../../api');
describe(BillingClient, () => {
const postAsync = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(ApiV2Client).mockImplementation(() => ({ postAsync }) as unknown as ApiV2Client);
});
it('unwraps the apiv2 `data` envelope for a checkout session', async () => {
postAsync.mockResolvedValue({
data: {
id: 'cs_123',
clientSecret: null,
url: 'https://checkout.stripe.com/pay',
},
});
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
const session = await client.createCheckoutSessionAsync('account-id', 'STARTER');
expect(session).toEqual({
id: 'cs_123',
clientSecret: null,
url: 'https://checkout.stripe.com/pay',
});
expect(postAsync).toHaveBeenCalledWith('stripe-auth/checkout', {
body: { accountId: 'account-id', planType: 'STARTER' },
});
});
it('unwraps the apiv2 `data` envelope for a customer portal session', async () => {
postAsync.mockResolvedValue({
data: { customerPortal: { url: 'https://billing.stripe.com' } },
});
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
const session = await client.createCustomerPortalSessionAsync('account-id');
expect(session).toEqual({ url: 'https://billing.stripe.com' });
expect(postAsync).toHaveBeenCalledWith('stripe-auth/customer-portal', {
body: { accountId: 'account-id' },
});
});
});
@@ -0,0 +1,96 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Permission, Role } from '../../graphql/generated';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import { Actor } from '../../user/User';
import { resolveBillingAccountAsync } from '../resolveAccount';
jest.mock('../../graphql/queries/AccountQuery');
describe(resolveBillingAccountAsync, () => {
const graphqlClient = {} as ExpoGraphqlClient;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
});
function account(name: string, role: Role): Actor['accounts'][number] {
return {
id: `${name}-id`,
name,
viewerUserPermission: { role },
} as Actor['accounts'][number];
}
function actor(accounts: Actor['accounts']): Actor {
return { id: 'actor-id', accounts } as Actor;
}
async function resolveAsync(
accounts: Actor['accounts'],
accountName?: string
): ReturnType<typeof resolveBillingAccountAsync> {
return await resolveBillingAccountAsync({
graphqlClient,
actor: actor(accounts),
accountName,
nonInteractive: true,
subscriptionFilter: 'unsubscribed',
});
}
it('automatically selects the only account with billing permission', async () => {
await expect(
resolveAsync([account('viewer', Role.ViewOnly), account('admin', Role.Admin)])
).resolves.toEqual({ id: 'admin-id', name: 'admin', subscription: null });
});
it.each([
['account owners', Role.Owner],
['custom roles with admin permission', Role.HasAdmin],
])('allows %s to manage billing', async (_case, role) => {
await expect(resolveAsync([account('team', role)])).resolves.toEqual({
id: 'team-id',
name: 'team',
subscription: null,
});
});
it('allows the owner of a personal account to manage billing', async () => {
const ownerAccount = {
...account('personal', Role.ViewOnly),
ownerUserActor: { __typename: 'User', id: 'actor-id', username: 'personal' },
} as Actor['accounts'][number];
await expect(resolveAsync([ownerAccount])).resolves.toEqual({
id: 'personal-id',
name: 'personal',
subscription: null,
});
});
it('rejects an explicitly selected account without billing permission', async () => {
await expect(resolveAsync([account('developer', Role.Developer)], 'developer')).rejects.toThrow(
'You must be an Owner or Admin of account "developer" to manage billing.'
);
expect(AccountQuery.getByNameAsync).not.toHaveBeenCalled();
});
it('rejects an account lookup without billing permission', async () => {
jest.mocked(AccountQuery.getByNameAsync).mockResolvedValue({
id: 'viewer-id',
name: 'viewer',
viewerUserPermission: { id: 'viewer-permission-id', permissions: [Permission.View] },
});
await expect(resolveAsync([], 'viewer')).rejects.toThrow(
'You must be an Owner or Admin of account "viewer" to manage billing.'
);
});
it('fails when no account has billing permission', async () => {
await expect(resolveAsync([account('viewer', Role.ViewOnly)])).rejects.toThrow(
'You must be an Owner or Admin of at least one account to manage billing.'
);
});
});
@@ -0,0 +1,37 @@
import { ApiV2Client } from '../api';
import type { CheckoutPlanType } from './plans';
export type CheckoutSession = {
id: string;
clientSecret: string | null;
url: string | null;
};
export type CustomerPortalSession = {
url: string;
};
export class BillingClient {
private readonly apiV2Client: ApiV2Client;
constructor(authInfo: { accessToken: string | null; sessionSecret: string | null }) {
this.apiV2Client = new ApiV2Client(authInfo);
}
async createCheckoutSessionAsync(
accountId: string,
planType: CheckoutPlanType
): Promise<CheckoutSession> {
const { data } = await this.apiV2Client.postAsync('stripe-auth/checkout', {
body: { accountId, planType },
});
return data;
}
async createCustomerPortalSessionAsync(accountId: string): Promise<CustomerPortalSession> {
const { data } = await this.apiV2Client.postAsync('stripe-auth/customer-portal', {
body: { accountId },
});
return data.customerPortal;
}
}
+30
View File
@@ -0,0 +1,30 @@
import Log, { link } from '../log';
import { ora } from '../ora';
import openBrowser = require('open');
export async function openOrPrintUrlAsync(
url: string,
{ label, open }: { label: string; open: boolean }
): Promise<void> {
if (!open) {
Log.log(`${label}: ${link(url)}`);
return;
}
const spinner = ora(`Opening ${label.toLowerCase()}`).start();
let opened = false;
try {
await openBrowser(url);
opened = true;
} catch (error) {
Log.debug(`Failed to open a web browser: ${error}`);
}
if (opened) {
spinner.succeed(`Opened ${label.toLowerCase()} in your browser`);
} else {
spinner.fail('Unable to open a web browser automatically.');
}
Log.log(`${label}: ${link(url)}`);
}
+23
View File
@@ -0,0 +1,23 @@
export const SUBSCRIBABLE_PLANS = {
starter: { planType: 'STARTER', label: 'Starter' },
production: { planType: 'PRODUCTION', label: 'Production' },
} as const;
export type PlanSlug = keyof typeof SUBSCRIBABLE_PLANS;
export type CheckoutPlanType = (typeof SUBSCRIBABLE_PLANS)[PlanSlug]['planType'];
export const PLAN_SLUGS = Object.keys(SUBSCRIBABLE_PLANS) as PlanSlug[];
export const FREE_PLAN_PRICE_ID = 'price_free';
export function formatStarterSubscribeCommand(accountName?: string): string {
return `eas billing:subscribe starter${accountName ? ` --account ${accountName}` : ''}`;
}
export function hasPaidSubscription(
subscription: {
planId?: string | null;
} | null
): boolean {
return subscription?.planId != null && subscription.planId !== FREE_PLAN_PRICE_ID;
}
@@ -0,0 +1,151 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Permission, Role } from '../graphql/generated';
import { AccountQuery, type AccountSubscriptionInfo } from '../graphql/queries/AccountQuery';
import { hasPaidSubscription } from './plans';
import { Actor } from '../user/User';
import { ExpoChoice, selectAsync } from '../prompts';
export type BillingAccount = {
id: string;
name: string;
subscription: AccountSubscriptionInfo | null;
};
type SubscriptionFilter = 'subscribed' | 'unsubscribed';
function billingPermissionError(accountName: string): Error {
return new Error(`You must be an Owner or Admin of account "${accountName}" to manage billing.`);
}
function hasBillingRole(actor: Actor, account: Actor['accounts'][number]): boolean {
if (account.ownerUserActor?.id === actor.id) {
return true;
}
const { role } = account.viewerUserPermission;
return role === Role.Admin || role === Role.Owner || role === Role.HasAdmin;
}
async function getAccountWithSubscriptionAsync(
graphqlClient: ExpoGraphqlClient,
account: { id: string; name: string }
): Promise<BillingAccount> {
const subscription = await AccountQuery.getSubscriptionAsync(graphqlClient, account.id);
return { id: account.id, name: account.name, subscription };
}
function toAccountChoice(account: BillingAccount, disabled: boolean): ExpoChoice<BillingAccount> {
const fallback = hasPaidSubscription(account.subscription) ? 'Paid' : 'Free';
return {
title: account.name,
value: account,
description: `Current plan: ${account.subscription?.name ?? fallback}`,
disabled,
};
}
async function resolveNamedAccountAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
accountName: string
): Promise<{ id: string; name: string }> {
const ownAccount = actor.accounts.find(account => account.name === accountName);
if (ownAccount) {
if (!hasBillingRole(actor, ownAccount)) {
throw billingPermissionError(ownAccount.name);
}
return ownAccount;
}
const account = await AccountQuery.getByNameAsync(graphqlClient, accountName).catch(() => null);
if (!account) {
const availableAccounts = actor.accounts
.filter(candidate => hasBillingRole(actor, candidate))
.map(candidate => candidate.name)
.join(', ');
throw new Error(
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
);
}
const { permissions } = account.viewerUserPermission;
if (!permissions.includes(Permission.Admin) && !permissions.includes(Permission.Own)) {
throw billingPermissionError(account.name);
}
return account;
}
export async function resolveBillingAccountAsync({
graphqlClient,
actor,
accountName,
nonInteractive,
subscriptionFilter,
}: {
graphqlClient: ExpoGraphqlClient;
actor: Actor;
accountName: string | undefined;
nonInteractive: boolean;
subscriptionFilter: SubscriptionFilter;
}): Promise<BillingAccount> {
if (accountName) {
const namedAccount = await resolveNamedAccountAsync(graphqlClient, actor, accountName);
const account = await getAccountWithSubscriptionAsync(graphqlClient, namedAccount);
if (subscriptionFilter === 'subscribed' && !hasPaidSubscription(account.subscription)) {
throw new Error(
`Account "${account.name}" does not have an active paid plan. Run eas billing:subscribe to subscribe.`
);
}
return account;
}
const billingAccounts = actor.accounts.filter(account => hasBillingRole(actor, account));
if (billingAccounts.length === 0) {
throw new Error('You must be an Owner or Admin of at least one account to manage billing.');
}
const accounts = await Promise.all(
billingAccounts.map(account => getAccountWithSubscriptionAsync(graphqlClient, account))
);
if (subscriptionFilter === 'unsubscribed' && accounts.length === 1) {
return accounts[0];
}
const subscribedAccounts = accounts.filter(account => hasPaidSubscription(account.subscription));
const unsubscribedAccounts = accounts.filter(
account => !hasPaidSubscription(account.subscription)
);
const eligibleAccounts =
subscriptionFilter === 'subscribed' ? subscribedAccounts : unsubscribedAccounts;
if (eligibleAccounts.length === 0) {
throw new Error(
subscriptionFilter === 'subscribed'
? 'No available accounts have an active paid plan. Run eas billing:subscribe to subscribe an account.'
: 'All available accounts already have a paid plan. Run eas billing:manage to change an existing subscription.'
);
}
if (eligibleAccounts.length === 1) {
return eligibleAccounts[0];
}
if (nonInteractive) {
throw new Error(
'The --account flag must be provided when running in `--non-interactive` mode and you can manage billing for more than one eligible account.'
);
}
const disabledAccounts = subscriptionFilter === 'unsubscribed' ? subscribedAccounts : [];
return await selectAsync(
'Select an account:',
[
...eligibleAccounts.map(account => toAccountChoice(account, false)),
...disabledAccounts.map(account => toAccountChoice(account, true)),
],
{
initial: eligibleAccounts[0],
warningMessageForDisabledEntries:
'This account already has a paid plan. Run eas billing:manage to change it.',
}
);
}
@@ -295,39 +295,41 @@ describe(Build.name, () => {
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
() => {
handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform, accountName);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1'
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildFreeTierIosLimitExceededError>(() => {
handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform, accountName);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
'Error 1'
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
});
+16 -3
View File
@@ -43,6 +43,7 @@ import {
import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { createFingerprintAsync } from '../fingerprint/cli';
@@ -209,7 +210,7 @@ export async function prepareBuildRequestForPlatformAsync<
});
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
handleBuildRequestError(error, job.platform, ctx.accountName);
}
} else {
throw new Error('Unknown localBuildMode.');
@@ -230,7 +231,11 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
VALIDATION_ERROR: RequestValidationError,
};
export function handleBuildRequestError(error: any, platform: Platform): never {
export function handleBuildRequestError(
error: any,
platform: Platform,
accountName?: string
): never {
logBuildRequestErrorDebugInfo(error);
const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors)
@@ -241,7 +246,15 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
| undefined;
if (graphQLErrorCode && graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
throw new ErrorClass(graphQLErrors[0]?.message);
const message = graphQLErrors[0]?.message;
const isFreeTierLimitError =
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
throw new ErrorClass(
isFreeTierLimitError
? `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`
: message
);
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
throw new EasBuildDownForMaintenanceError(
`EAS Build is down for maintenance. Try again later. Check ${link(
@@ -0,0 +1,163 @@
import open from 'open';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { BillingClient } from '../../../billing/billingClient';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { printJsonOnlyOutput } from '../../../utils/json';
import BillingManage from '../manage';
jest.mock('open');
jest.mock('../../../billing/billingClient');
jest.mock('../../../graphql/queries/AccountQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
jest.mock('../../../ora');
function billingAccount(
id: string,
name: string
): { id: string; name: string; viewerUserPermission: { role: Role } } {
return { id, name, viewerUserPermission: { role: Role.Admin } };
}
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
describe(BillingManage, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const account = billingAccount('account-id', 'testaccount');
const createCustomerPortalSessionAsync = jest.fn();
function createCommand(
argv: string[],
accounts: ReturnType<typeof billingAccount>[] = [account]
): BillingManage {
const command = new BillingManage(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
loggedIn: {
graphqlClient,
actor: { id: 'actor-id', accounts },
authenticationInfo: { accessToken: 'token', sessionSecret: null },
},
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
jest
.mocked(BillingClient)
.mockImplementation(() => ({ createCustomerPortalSessionAsync }) as unknown as BillingClient);
});
it('creates a customer portal session and prints the URL as JSON', async () => {
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session/abc',
});
await createCommand(['--json']).runAsync();
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('account-id');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
customerPortalUrl: 'https://billing.stripe.com/session/abc',
});
});
it('opens the customer portal in a browser when interactive, without re-encoding the URL', async () => {
const url = 'https://billing.stripe.com/session/abc%2Fdef';
createCustomerPortalSessionAsync.mockResolvedValue({ url });
jest.mocked(open).mockResolvedValue({} as never);
await createCommand([]).runAsync();
expect(open).toHaveBeenCalledWith(url);
});
it('prints the customer portal URL without opening a browser with --no-open', async () => {
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session/abc',
});
await createCommand(['--no-open']).runAsync();
expect(open).not.toHaveBeenCalled();
});
it('only shows accounts with active paid plans', async () => {
const freeAccount = billingAccount('free-account-id', 'free-account');
const productionAccount = billingAccount('production-account-id', 'production-account');
jest.mocked(AccountQuery.getSubscriptionAsync).mockImplementation(async (_client, accountId) =>
accountId === freeAccount.id
? null
: {
id: `subscription-${accountId}`,
name: accountId === productionAccount.id ? 'Production' : 'Starter',
planId: `price-${accountId}`,
}
);
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[1].value);
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session',
});
await createCommand([], [account, freeAccount, productionAccount]).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select an account:',
[
{
title: 'testaccount',
value: {
id: 'account-id',
name: 'testaccount',
subscription: {
id: 'subscription-account-id',
name: 'Starter',
planId: 'price-account-id',
},
},
description: 'Current plan: Starter',
disabled: false,
},
{
title: 'production-account',
value: {
id: 'production-account-id',
name: 'production-account',
subscription: {
id: 'subscription-production-account-id',
name: 'Production',
planId: 'price-production-account-id',
},
},
description: 'Current plan: Production',
disabled: false,
},
],
expect.any(Object)
);
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('production-account-id');
});
it('rejects an account without an active paid plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
await expect(createCommand(['--account', 'testaccount']).runAsync()).rejects.toThrow(
'Account "testaccount" does not have an active paid plan. Run eas billing:subscribe to subscribe.'
);
expect(createCustomerPortalSessionAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,308 @@
import open from 'open';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { BillingClient } from '../../../billing/billingClient';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { printJsonOnlyOutput } from '../../../utils/json';
import BillingSubscribe from '../subscribe';
jest.mock('open');
jest.mock('../../../billing/billingClient');
jest.mock('../../../graphql/queries/AccountQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
jest.mock('../../../ora');
function billingAccount(
id: string,
name: string
): { id: string; name: string; viewerUserPermission: { role: Role } } {
return { id, name, viewerUserPermission: { role: Role.Admin } };
}
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
const FREE_SUBSCRIPTION = { id: 'sub_free', name: 'Free', planId: 'price_free' };
describe(BillingSubscribe, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const account = billingAccount('account-id', 'testaccount');
const createCheckoutSessionAsync = jest.fn();
const logEvent = jest.fn();
function createCommand(
argv: string[],
accounts: ReturnType<typeof billingAccount>[] = [account]
): BillingSubscribe {
const command = new BillingSubscribe(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
analytics: { logEvent },
loggedIn: {
graphqlClient,
actor: { id: 'actor-id', accounts },
authenticationInfo: { accessToken: 'token', sessionSecret: null },
},
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
jest
.mocked(BillingClient)
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
});
it('creates a checkout session for a plan and prints the URL as JSON', async () => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs_123',
url: 'https://checkout.stripe.com/c/pay/cs_123',
});
await createCommand(['starter', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_123',
alreadySubscribed: false,
});
});
it('maps each plan slug to its server PlanType', async () => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['production', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
});
it('prompts for a plan when one is not provided', async () => {
jest.mocked(selectAsync).mockResolvedValue('production');
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith('Select a plan:', [
{ title: 'Starter', value: 'starter' },
{ title: 'Production', value: 'production' },
]);
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
expect(jest.mocked(AccountQuery.getSubscriptionAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(selectAsync).mock.invocationCallOrder[0]
);
});
it('prompts for an account before prompting for a plan', async () => {
jest
.mocked(selectAsync)
.mockImplementationOnce(async (_message, choices) => choices[0].value)
.mockResolvedValueOnce('production');
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(
[],
[account, billingAccount('second-account-id', 'second-account')]
).runAsync();
expect(jest.mocked(selectAsync).mock.calls.map(([message]) => message)).toEqual([
'Select an account:',
'Select a plan:',
]);
});
it('requires a plan in non-interactive mode', async () => {
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'The plan argument is required in non-interactive mode.'
);
expect(selectAsync).not.toHaveBeenCalled();
});
it('shows current plans and disables subscribed accounts in the account prompt', async () => {
const subscribedAccount = billingAccount('subscribed-id', 'subscribed');
const freeAccount = billingAccount('free-id', 'free');
const secondFreeAccount = billingAccount('second-free-id', 'second-free');
jest
.mocked(AccountQuery.getSubscriptionAsync)
.mockImplementation(async (_client, accountId) =>
accountId === subscribedAccount.id ? STARTER_SUBSCRIPTION : null
);
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[0].value);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(
['starter'],
[subscribedAccount, freeAccount, secondFreeAccount]
).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select an account:',
[
{
title: 'free',
value: { id: 'free-id', name: 'free', subscription: null },
description: 'Current plan: Free',
disabled: false,
},
{
title: 'second-free',
value: { id: 'second-free-id', name: 'second-free', subscription: null },
description: 'Current plan: Free',
disabled: false,
},
{
title: 'subscribed',
value: {
id: 'subscribed-id',
name: 'subscribed',
subscription: STARTER_SUBSCRIPTION,
},
description: 'Current plan: Starter',
disabled: true,
},
],
{
initial: { id: 'free-id', name: 'free', subscription: null },
warningMessageForDisabledEntries:
'This account already has a paid plan. Run eas billing:manage to change it.',
}
);
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('free-id', 'STARTER');
});
it.each([
['an explicitly selected account is', ['production', '--account', 'testaccount', '--json']],
['the only account is', ['production', '--json']],
])('returns the current plan when %s already subscribed', async (_case, argv) => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(argv).runAsync();
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
alreadySubscribed: true,
currentPlan: 'Starter',
});
});
it('reports an explicitly selected subscribed account before prompting for a plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(['--account', 'testaccount']).runAsync();
expect(selectAsync).not.toHaveBeenCalled();
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Account testaccount is already subscribed to the Starter plan.'
);
expect(Log.log).toHaveBeenCalledWith('To change or cancel your plan, run eas billing:manage.');
});
it('treats the free plan as not subscribed', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(FREE_SUBSCRIPTION);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['starter', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
});
it.each([['--non-interactive'], ['--no-open']])(
'prints the checkout URL without opening a browser with %s',
async flag => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs',
url: 'https://checkout.stripe.com/pay',
});
await createCommand(['starter', flag]).runAsync();
expect(open).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
expect(link).toHaveBeenCalledWith('https://checkout.stripe.com/pay');
}
);
it('returns structured JSON with the checkout URL and does not open a browser', async () => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs',
url: 'https://checkout.stripe.com/pay',
});
await createCommand(['starter', '--json']).runAsync();
expect(open).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
checkoutUrl: 'https://checkout.stripe.com/pay',
alreadySubscribed: false,
});
});
it.each([
['checkout', null],
['already subscribed', STARTER_SUBSCRIPTION],
])(
'does not emit null JSON values, which printJsonOnlyOutput strips (%s)',
async (_case, subscription) => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(subscription);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['starter', '--json']).runAsync();
const [payload] = jest.mocked(printJsonOnlyOutput).mock.calls[0];
expect(Object.values(payload)).not.toContain(null);
}
);
it.each([
['--json', true],
['--no-open', false],
])('reports the subscribe command to analytics (%s)', async (flag, json) => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['production', flag]).runAsync();
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
account_id: 'account-id',
json,
non_interactive: json,
already_subscribed: false,
plan: 'production',
});
});
it('reports an idempotent subscribe to analytics without a plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(['production', '--json']).runAsync();
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
account_id: 'account-id',
json: true,
non_interactive: true,
already_subscribed: true,
});
});
it('throws when the checkout session has no URL', async () => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: null });
await expect(createCommand(['starter', '--json']).runAsync()).rejects.toThrow(
'The checkout session did not include a URL.'
);
expect(Log.log).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import { Flags } from '@oclif/core';
import { BillingClient } from '../../billing/billingClient';
import { openOrPrintUrlAsync } from '../../billing/openUrl';
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ora } from '../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BillingManage extends EasCommand {
static override description = 'manage billing for an account with an active paid EAS plan';
static override flags = {
account: Flags.string({
char: 'a',
description:
'Account with an active paid plan to manage. Defaults to your account when only one is eligible.',
}),
'no-open': Flags.boolean({
description: 'Only print the customer portal URL instead of opening it in a browser',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BillingManage);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient, actor, authenticationInfo },
} = await this.getContextAsync(BillingManage, { nonInteractive });
const account = await resolveBillingAccountAsync({
graphqlClient,
actor,
accountName: flags.account,
nonInteractive,
subscriptionFilter: 'subscribed',
});
const billingClient = new BillingClient(authenticationInfo);
const spinner = ora(`Creating a customer portal session for ${account.name}`).start();
let portalUrl: string;
try {
const { url } = await billingClient.createCustomerPortalSessionAsync(account.id);
portalUrl = url;
spinner.succeed(`Created a customer portal session for ${account.name}`);
} catch (error) {
spinner.fail('Failed to create a customer portal session');
throw error;
}
if (json) {
printJsonOnlyOutput({ customerPortalUrl: portalUrl });
return;
}
await openOrPrintUrlAsync(portalUrl, {
label: 'Customer portal',
open: !flags['no-open'] && !nonInteractive,
});
}
}
@@ -0,0 +1,150 @@
import { Args, Flags } from '@oclif/core';
import { BillingEvent } from '../../analytics/AnalyticsManager';
import { BillingClient } from '../../billing/billingClient';
import { openOrPrintUrlAsync } from '../../billing/openUrl';
import { PLAN_SLUGS, PlanSlug, SUBSCRIBABLE_PLANS, hasPaidSubscription } from '../../billing/plans';
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { ora } from '../../ora';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BillingSubscribe extends EasCommand {
static override description = 'subscribe a Free account to an EAS plan';
static override args = {
PLAN: Args.string({
description: 'plan to subscribe to. Required in non-interactive mode.',
required: false,
options: [...PLAN_SLUGS],
}),
};
static override flags = {
account: Flags.string({
char: 'a',
description: 'Account to subscribe. Defaults to your account when you only have one.',
}),
'no-open': Flags.boolean({
description: 'Only print the checkout page URL instead of opening it in a browser',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
const {
args: { PLAN },
flags,
} = await this.parse(BillingSubscribe);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
let planSlug = PLAN as PlanSlug | undefined;
const {
analytics,
loggedIn: { graphqlClient, actor, authenticationInfo },
} = await this.getContextAsync(BillingSubscribe, { nonInteractive });
const account = await resolveBillingAccountAsync({
graphqlClient,
actor,
accountName: flags.account,
nonInteractive,
subscriptionFilter: 'unsubscribed',
});
const analyticsProperties = {
account_id: account.id,
json,
non_interactive: nonInteractive,
};
const { subscription } = account;
if (hasPaidSubscription(subscription)) {
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
...analyticsProperties,
already_subscribed: true,
});
if (json) {
printJsonOnlyOutput({
alreadySubscribed: true,
currentPlan: subscription?.name ?? null,
});
return;
}
Log.warn(
`Account ${account.name} is already subscribed${
subscription?.name ? ` to the ${subscription.name} plan` : ''
}.`
);
Log.log('To change or cancel your plan, run eas billing:manage.');
return;
}
if (!planSlug && nonInteractive) {
throw new Error('The plan argument is required in non-interactive mode.');
}
if (!planSlug) {
planSlug = await selectAsync(
'Select a plan:',
PLAN_SLUGS.map(slug => ({
title: SUBSCRIBABLE_PLANS[slug].label,
value: slug,
}))
);
}
const plan = SUBSCRIBABLE_PLANS[planSlug];
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
...analyticsProperties,
already_subscribed: false,
plan: planSlug,
});
const billingClient = new BillingClient(authenticationInfo);
const spinner = ora(`Creating a checkout session for the ${plan.label} plan`).start();
let checkoutUrl: string;
try {
const session = await billingClient.createCheckoutSessionAsync(account.id, plan.planType);
if (!session.url) {
throw new Error('The checkout session did not include a URL.');
}
checkoutUrl = session.url;
spinner.succeed(`Created a checkout session for the ${plan.label} plan`);
} catch (error) {
spinner.fail('Failed to create a checkout session');
throw error;
}
if (json) {
printJsonOnlyOutput({ checkoutUrl, alreadySubscribed: false });
return;
}
Log.log(
`Complete your subscription to the ${plan.label} plan for ${account.name} in Stripe checkout.`
);
await openOrPrintUrlAsync(checkoutUrl, {
label: 'Checkout page',
open: !flags['no-open'] && !nonInteractive,
});
}
}
@@ -23,7 +23,7 @@ import {
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../../simulator/expoGo';
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -80,9 +80,7 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockResolveExpoGoApplicationArchiveUrlAsync = jest.mocked(
resolveExpoGoApplicationArchiveUrlAsync
);
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
const mockOra = jest.mocked(ora);
const mockPromptAsync = jest.mocked(promptAsync);
@@ -166,9 +164,7 @@ describe(Simulator, () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(
'https://example.test/expo-go.tar.gz'
);
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
@@ -541,12 +537,11 @@ describe(Simulator, () => {
});
it.each([
['ios', AppPlatform.Ios, 'https://example.test/expo-go.tar.gz'],
['android', AppPlatform.Android, 'https://example.test/expo-go.apk'],
['ios', AppPlatform.Ios],
['android', AppPlatform.Android],
] as const)(
'resolves and forwards the %s Expo Go archive when --expo-go is passed',
async (platformFlag, appPlatform, applicationArchiveUrl) => {
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(applicationArchiveUrl);
'forwards Expo Go and the project SDK for %s when --expo-go is passed',
async (platformFlag, appPlatform) => {
const { command } = createCommand([
'--platform',
platformFlag,
@@ -556,20 +551,108 @@ describe(Simulator, () => {
await command.runAsync();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).toHaveBeenCalledWith({
platform: platformFlag,
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
projectDir,
sdkVersion: undefined,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl,
expoGo: true,
sdkVersion: '55.0.0',
platform: appPlatform,
})
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.not.objectContaining({ applicationArchiveUrl: expect.anything() })
);
}
);
it('uses --sdk-version to select Expo Go', async () => {
mockResolveExpoGoSdkVersionAsync.mockResolvedValueOnce('57.0.0');
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--expo-go',
'--sdk-version',
'57.0.0',
]);
await command.runAsync();
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
projectDir,
sdkVersion: '57.0.0',
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ expoGo: true, sdkVersion: '57.0.0' })
);
});
it('forwards repeated launch arguments and a URL to open', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
'--launch-arg',
'--uitesting',
'--launch-arg',
'true',
'--open-url',
' exp://example.test ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
launchArgs: ['--uitesting', 'true'],
openUrl: 'exp://example.test',
})
);
});
it.each([
['--launch-arg', '--uitesting'],
['--open-url', 'exp://example.test'],
])('rejects %s without an application source', async (launchFlag, launchValue) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
launchFlag,
launchValue,
]);
await expect(command.runAsync()).rejects.toThrow(
'Launch options require an application source.'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('rejects --sdk-version without --expo-go', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--sdk-version',
'57',
]);
await expect(command.runAsync()).rejects.toThrow(
'The --sdk-version flag can only be used with --expo-go.'
);
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('rejects passing --build-id and --application-archive-url together', async () => {
const { command } = createCommand([
'--platform',
@@ -599,7 +682,7 @@ describe(Simulator, () => {
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockResolveExpoGoApplicationArchiveUrlAsync).not.toHaveBeenCalled();
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
@@ -28,7 +28,7 @@ import {
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../simulator/expoGo';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
@@ -87,6 +87,19 @@ export default class Simulator extends EasCommand {
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
exclusive: ['build-id', 'application-archive-url'],
}),
'sdk-version': Flags.string({
description:
'Expo SDK version used to select Expo Go when --expo-go is passed. Defaults to the current project SDK.',
}),
'launch-arg': Flags.string({
description:
'Argument passed to the installed application when it launches. Repeat for multiple arguments.',
multiple: true,
}),
'open-url': Flags.string({
description:
'Expo or development-client URL to open in the installed application after it launches.',
}),
type: Flags.option({
description: 'Type of simulator session to create',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
@@ -156,6 +169,23 @@ export default class Simulator extends EasCommand {
const deviceIdentifier = flags.device?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
const launchArgs = flags['launch-arg'];
const openUrl = flags['open-url']?.trim() || undefined;
if (sdkVersionFromFlag && !flags['expo-go']) {
throw new EasCommandError('The --sdk-version flag can only be used with --expo-go.');
}
if (
(launchArgs?.length || openUrl) &&
!buildId &&
!applicationArchiveUrlFromFlag &&
!flags['expo-go']
) {
throw new EasCommandError(
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
);
}
await loadSimulatorEnvAsync(projectDir);
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
@@ -166,12 +196,9 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const applicationArchiveUrl = flags['expo-go']
? await resolveExpoGoApplicationArchiveUrlAsync({
platform: platform === AppPlatform.Ios ? 'ios' : 'android',
projectDir,
})
: applicationArchiveUrlFromFlag;
const expoGoSdkVersion = flags['expo-go']
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
: undefined;
if (existingDeviceRunSessionId) {
Log.warn(
@@ -195,7 +222,12 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
deviceIdentifier,
...(buildId ? { buildId } : {}),
...(applicationArchiveUrl ? { applicationArchiveUrl } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
: {}),
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
...(launchArgs?.length ? { launchArgs } : {}),
...(openUrl ? { openUrl } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
+31 -3
View File
@@ -5236,12 +5236,12 @@ export type CreateDeviceRunSessionInput = {
appId: Scalars['ID']['input'];
/**
* Application archive URL to download, install, and launch before the simulator session
* becomes available. Mutually exclusive with buildId.
* becomes available. Mutually exclusive with buildId and expoGo.
*/
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
/**
* EAS Build to install and launch before the simulator session becomes available.
* Mutually exclusive with applicationArchiveUrl.
* Mutually exclusive with applicationArchiveUrl and expoGo.
*/
buildId?: InputMaybe<Scalars['ID']['input']>;
/**
@@ -5251,6 +5251,17 @@ export type CreateDeviceRunSessionInput = {
* default device.
*/
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Install and launch Expo Go before the simulator session becomes available. The server resolves
* the platform-specific application archive. Mutually exclusive with buildId and
* applicationArchiveUrl.
*/
expoGo?: InputMaybe<Scalars['Boolean']['input']>;
/**
* Arguments passed to the installed application when it is launched. Requires buildId,
* applicationArchiveUrl, or expoGo.
*/
launchArgs?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Stop the session automatically after this many minutes without observed
* session activity. Must be positive and smaller than the session's maximum
@@ -5271,12 +5282,22 @@ export type CreateDeviceRunSessionInput = {
* session is unnamed and clients fall back to identifying it by id.
*/
name?: InputMaybe<Scalars['String']['input']>;
/**
* Expo or development-client URL to open after launching the installed application. Requires
* buildId, applicationArchiveUrl, or expoGo.
*/
openUrl?: InputMaybe<Scalars['String']['input']>;
/**
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
* If omitted, consumers treat the session as pinned to "latest".
*/
packageVersion?: InputMaybe<Scalars['String']['input']>;
platform: AppPlatform;
/**
* Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is
* true. If omitted, the current Expo Go archive for the platform is used.
*/
sdkVersion?: InputMaybe<Scalars['String']['input']>;
type: DeviceRunSessionType;
};
@@ -14657,7 +14678,7 @@ export type AccountByNameQueryVariables = Exact<{
}>;
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string } } };
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string, viewerUserPermission: { __typename?: 'UserPermission', id: string, permissions: Array<Permission> } } } };
export type AccountFullUsageQueryVariables = Exact<{
accountId: Scalars['String']['input'];
@@ -14685,6 +14706,13 @@ export type AccountBillingPeriodQueryVariables = Exact<{
export type AccountBillingPeriodQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any } } } };
export type AccountSubscriptionQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type AccountSubscriptionQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, planId?: string | null } | null } } };
export type AppByIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -4,8 +4,12 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
AccountByNameQuery as AccountByNameQueryType,
AccountByNameQueryVariables,
AccountFullUsageQuery as AccountFullUsageQueryType,
AccountFullUsageQueryVariables,
AccountSubscriptionQuery as AccountSubscriptionQueryType,
AccountSubscriptionQueryVariables,
AccountUsageForOverageWarningQuery,
AccountUsageForOverageWarningQueryVariables,
} from '../generated';
@@ -17,21 +21,28 @@ import {
} from '../types/Account';
export type AccountFullUsageData = NonNullable<AccountFullUsageQueryType['account']['byId']>;
export type AccountSubscriptionInfo = NonNullable<
NonNullable<AccountSubscriptionQueryType['account']['byId']>['subscription']
>;
export const AccountQuery = {
async getByNameAsync(
graphqlClient: ExpoGraphqlClient,
accountName: string
): Promise<{ id: string; name: string } | null> {
): Promise<AccountByNameQueryType['account']['byName'] | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<{ account: { byName: { id: string; name: string } | null } }>(
.query<AccountByNameQueryType, AccountByNameQueryVariables>(
gql`
query AccountByName($accountName: String!) {
account {
byName(accountName: $accountName) {
id
name
viewerUserPermission {
id
permissions
}
}
}
}
@@ -276,4 +287,34 @@ export const AccountQuery = {
end: new Date(end),
};
},
async getSubscriptionAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<AccountSubscriptionInfo | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AccountSubscriptionQueryType, AccountSubscriptionQueryVariables>(
gql`
query AccountSubscription($accountId: String!) {
account {
byId(accountId: $accountId) {
id
subscription {
id
name
planId
}
}
}
}
`,
{ accountId },
{ additionalTypenames: ['Account', 'SubscriptionDetails'] }
)
.toPromise()
);
return data.account.byId?.subscription ?? null;
},
};
@@ -1,65 +1,40 @@
import spawnAsync from '@expo/spawn-async';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { resolveExpoGoApplicationArchiveUrlAsync } from '../expoGo';
import { resolveExpoGoSdkVersionAsync } from '../expoGo';
jest.mock('@expo/spawn-async');
jest.mock('../../project/detectProjectSdkVersionAsync');
const projectDir = '/test/project';
const mockDetectProjectSdkVersionAsync = jest.mocked(detectProjectSdkVersionAsync);
const mockSpawnAsync = jest.mocked(spawnAsync);
describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
describe(resolveExpoGoSdkVersionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockDetectProjectSdkVersionAsync.mockResolvedValue('55.0.0');
mockSpawnAsync.mockResolvedValue({
stdout: 'https://example.test/expo-go.tar.gz\n',
} as never);
});
it.each(['ios', 'android'] as const)(
'resolves the Expo Go URL for SDK 55 on %s',
async platform => {
await expect(resolveExpoGoApplicationArchiveUrlAsync({ platform, projectDir })).resolves.toBe(
'https://example.test/expo-go.tar.gz'
);
expect(mockSpawnAsync).toHaveBeenCalledWith(
'npx',
['--yes', 'expo-go', 'url', platform, '55'],
{
cwd: projectDir,
stdio: 'pipe',
}
);
}
);
it('normalizes the detected project SDK version', async () => {
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).resolves.toBe('55.0.0');
expect(mockDetectProjectSdkVersionAsync).toHaveBeenCalledWith(projectDir);
});
it.each([
[
'plain output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from https://example.test/Expo-Go-57.0.9.tar.gz\n',
],
[
'Markdown hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from [https://example.test/Expo-Go-57.0.9.tar.gz](https://example.test/Expo-Go-57.0.9.tar.gz)\n',
],
[
'terminal hyperlink output',
'Resolving the correct Expo Go version...\n' +
'Download Expo Go from \u001B]8;;https://example.test/Expo-Go-57.0.9.tar.gz\u0007' +
'https://example.test/Expo-Go-57.0.9.tar.gz\u001B]8;;\u0007\n',
],
])('extracts the URL from %s', async (_description, stdout) => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
['57', '57.0.0'],
['57.0.0', '57.0.0'],
['57.0.9', '57.0.0'],
])('normalizes an explicit SDK version (%s)', async (sdkVersion, expectedSdkVersion) => {
await expect(resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion })).resolves.toBe(
expectedSdkVersion
);
expect(mockDetectProjectSdkVersionAsync).not.toHaveBeenCalled();
});
it('rejects an invalid explicit SDK version', async () => {
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).resolves.toBe('https://example.test/Expo-Go-57.0.9.tar.gz');
resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: 'not-a-version' })
).rejects.toThrow(
'Unable to parse Expo SDK version "not-a-version". Pass a major or semantic version, such as --sdk-version 57.'
);
expect(mockDetectProjectSdkVersionAsync).not.toHaveBeenCalled();
});
it.each([undefined, 'UNVERSIONED'])(
@@ -67,36 +42,9 @@ describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
async sdkVersion => {
mockDetectProjectSdkVersionAsync.mockResolvedValue(sdkVersion);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow(
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).rejects.toThrow(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected."
);
expect(mockSpawnAsync).not.toHaveBeenCalled();
}
);
it('reports how to diagnose an expo-go failure', async () => {
mockSpawnAsync.mockRejectedValue(new Error('network unavailable') as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'android', projectDir })
).rejects.toThrow(
'Failed to resolve the Expo Go download URL for SDK 55 on android. network unavailable ' +
'Run "npx expo-go url android 55" to diagnose the problem'
);
});
it.each([
'',
'not a URL',
'file:///tmp/expo-go.app',
'Resolving the correct Expo Go version...\nDownload Expo Go from nowhere\n',
])('rejects an invalid expo-go URL (%s)', async stdout => {
mockSpawnAsync.mockResolvedValue({ stdout } as never);
await expect(
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
).rejects.toThrow('expo-go returned an invalid download URL for SDK 55 on ios.');
});
});
+10 -53
View File
@@ -1,71 +1,28 @@
import { stripVTControlCharacters } from 'util';
import spawnAsync from '@expo/spawn-async';
import semver from 'semver';
import { EasCommandError } from '../commandUtils/errors';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
export type ExpoGoPlatform = 'android' | 'ios';
const EXPO_GO_DOWNLOAD_MESSAGE = 'Download Expo Go from';
function extractApplicationArchiveUrl(stdout: string): string | undefined {
const output = stripVTControlCharacters(stdout);
const outputLines = output.trim().split(/\r?\n/);
const downloadMessageLine = outputLines.find(line => line.includes(EXPO_GO_DOWNLOAD_MESSAGE));
const bareUrlOutput = outputLines.length === 1 ? outputLines[0] : undefined;
const lineWithUrl = downloadMessageLine ?? bareUrlOutput;
return lineWithUrl?.match(/https?:\/\/[^\s\])]+/)?.[0];
}
export async function resolveExpoGoApplicationArchiveUrlAsync({
platform,
export async function resolveExpoGoSdkVersionAsync({
projectDir,
sdkVersion: sdkVersionFromFlag,
}: {
platform: ExpoGoPlatform;
projectDir: string;
sdkVersion?: string;
}): Promise<string> {
const sdkVersion = await detectProjectSdkVersionAsync(projectDir);
const sdkVersion = sdkVersionFromFlag ?? (await detectProjectSdkVersionAsync(projectDir));
const sdkMajorVersion = semver.coerce(sdkVersion)?.major;
if (!sdkMajorVersion) {
if (sdkVersionFromFlag) {
throw new EasCommandError(
`Unable to parse Expo SDK version "${sdkVersionFromFlag}". Pass a major or semantic version, such as --sdk-version 57.`
);
}
throw new EasCommandError(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected. " +
'Make sure the project has Expo installed and a valid app config, or use --build-id or --application-archive-url instead.'
);
}
const args = ['--yes', 'expo-go', 'url', platform, String(sdkMajorVersion)];
let stdout: string;
try {
({ stdout } = await spawnAsync('npx', args, {
cwd: projectDir,
stdio: 'pipe',
}));
} catch (error) {
const reason = error instanceof Error ? ` ${error.message}` : '';
throw new EasCommandError(
`Failed to resolve the Expo Go download URL for SDK ${sdkMajorVersion} on ${platform}.${reason} ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
const applicationArchiveUrl = extractApplicationArchiveUrl(stdout);
try {
if (!applicationArchiveUrl) {
throw new Error('Missing URL');
}
const parsedUrl = new URL(applicationArchiveUrl);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Unsupported protocol');
}
} catch {
throw new EasCommandError(
`expo-go returned an invalid download URL for SDK ${sdkMajorVersion} on ${platform}. ` +
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
);
}
return applicationArchiveUrl;
return `${sdkMajorVersion}.0.0`;
}
@@ -462,6 +462,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Upgrade your plan to continue service.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/));
});
@@ -505,6 +510,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
});
it('at tier: displays a paid-plan limit-reached message with pay-as-you-go warning', () => {
@@ -543,6 +553,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
});
it('over tier: displays a paid-plan overage count and accrued cost', () => {
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { formatStarterSubscribeCommand } from '../../billing/plans';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import Log, { link } from '../../log';
@@ -136,6 +137,7 @@ export function displayOverageWarning({
overageCostCents: number;
}): void {
const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`;
const upgradeHint = ` Run ${formatStarterSubscribeCommand(name)} to upgrade to the Starter plan.`;
if (tier === 'approaching') {
const percentUsed = calculatePercentUsed(planValue, limit);
@@ -149,7 +151,8 @@ export function displayOverageWarning({
Log.warn(
hasFreePlan
? "You won't be able to start new builds once you reach the limit. " +
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false })
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false }) +
upgradeHint
: 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
@@ -163,7 +166,8 @@ export function displayOverageWarning({
Log.warn(
hasFreePlan
? 'New builds are blocked until your billing period resets. ' +
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false })
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false }) +
upgradeHint
: 'Additional builds will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "22.3.0",
"version": "22.4.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "22.3.0",
"@expo/build-tools": "22.4.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "22.1.0",
"version": "22.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -12,7 +12,7 @@ import type { BuildStepOutputAccessor } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput } from './BuildStepInput';
import { BuildStepRuntimeError } from './errors';
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from './errors';
import {
resolveInterpolatedTarget,
stringifyInterpolatedResult,
@@ -77,7 +77,20 @@ export class BuildStepCompositeFunctionScope {
if (this.parent && !this.parent.isActive(evaluate, runByDefault)) {
return false;
}
this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate, runByDefault);
if (this.cachedIsActive === undefined) {
try {
this.cachedIsActive = this.evaluateCallIfCondition(evaluate, runByDefault);
} catch (err) {
// An unevaluable gate skips the call, cache the skip before throwing.
// Only the first child reports the error, the rest skip silently.
this.cachedIsActive = false;
throw new BuildStepConditionEvaluationError(
`composite function call "${this.compositeFunctionPath}"`,
this.ifCondition ?? '',
{ cause: err instanceof Error ? err : undefined }
);
}
}
return this.cachedIsActive;
}
+6 -3
View File
@@ -7,6 +7,7 @@ import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { CompositeBuildStep } from './CompositeBuildStep';
import { StepMetricResult } from './StepMetrics';
import { BuildStepConditionEvaluationError } from './errors';
import { AnchorHooks, HookEntry } from './hooks';
import { evaluateIfCondition } from './utils/jsepEval';
@@ -279,15 +280,17 @@ export async function executeHookStepsAsync(
return { failedLocally, firstError };
}
// Shared wording for unevaluable `if:` gates. Callers pass the step's own
// `if:`, but the throw may come from a composite call-site `if:` evaluated via
// the step's scope. In that case the quoted condition is not the one that failed.
// Shared wording for unevaluable `if:` gates.
function logConditionEvaluationError(
logger: bunyan,
err: unknown,
subject: string,
ifCondition: string | undefined
): void {
if (err instanceof BuildStepConditionEvaluationError) {
({ subject, ifCondition } = err);
err = err.cause ?? err;
}
logger.error({ err });
logger.error(
`Runner failed to evaluate if it should execute ${subject}${
@@ -2,16 +2,26 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
import { instance, mock, when } from 'ts-mockito';
import { createGlobalContextMock } from './utils/context';
import { getError } from './utils/error';
import { BuildStep } from '../BuildStep';
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildStepConditionEvaluationError } from '../errors';
import { interpolateJobContext } from '../interpolation';
describe(BuildStepCompositeFunctionScope, () => {
const baseContext = {} as unknown as JobInterpolationContext;
function makeScope(): BuildStepCompositeFunctionScope {
function makeScope({
ifCondition,
parent,
compositeFunctionPath = 'test-action',
}: {
ifCondition?: string;
parent?: BuildStepCompositeFunctionScope;
compositeFunctionPath?: string;
} = {}): BuildStepCompositeFunctionScope {
const ctx = createGlobalContextMock();
const versionOutput = mock<BuildStepOutput>();
@@ -29,7 +39,9 @@ describe(BuildStepCompositeFunctionScope, () => {
greeting.set('hello');
return new BuildStepCompositeFunctionScope({
ctx,
compositeFunctionPath: 'test-action',
parent,
ifCondition,
compositeFunctionPath,
inputs: new Map([['greeting', greeting]]),
providedInputKeys: new Set(['greeting']),
childrenByLocalId: new Map([['build', instance(innerStep)]]),
@@ -69,5 +81,46 @@ describe(BuildStepCompositeFunctionScope, () => {
expect(scope.isActive(neverEvaluate, true)).toBe(true);
expect(scope.isActive(neverEvaluate, false)).toBe(true);
});
it('wraps an evaluation failure with the call as subject, its condition, and the raw cause', () => {
const rawError = new Error('boom');
const scope = makeScope({ ifCondition: '${{ broken }}' });
const error = getError(() =>
scope.isActive(() => {
throw rawError;
}, true)
);
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.subject).toBe('composite function call "test-action"');
expect(error.ifCondition).toBe('${{ broken }}');
expect(error.cause).toBe(rawError);
});
it('reports an evaluation failure once; later calls skip without re-evaluating', () => {
let evaluations = 0;
const evaluate = (): boolean => {
evaluations += 1;
throw new Error('boom');
};
const scope = makeScope({ ifCondition: '${{ broken }}' });
expect(() => scope.isActive(evaluate, true)).toThrow(BuildStepConditionEvaluationError);
expect(scope.isActive(evaluate, true)).toBe(false);
expect(evaluations).toBe(1);
});
it('propagates a parent call-site failure naming the parent scope, not the child', () => {
const parent = makeScope({
ifCondition: '${{ broken }}',
compositeFunctionPath: 'parent-action',
});
const child = makeScope({ parent });
const error = getError(() =>
child.isActive(() => {
throw new Error('boom');
}, true)
);
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.subject).toBe('composite function call "parent-action"');
});
});
});
@@ -4,6 +4,7 @@ import { Hooks, Step } from '@expo/eas-build-job';
import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils';
import { createGlobalContextMock } from './utils/context';
import { createRecordingLogger } from './utils/logger';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepStatus } from '../BuildStep';
@@ -13,17 +14,20 @@ import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { StepsConfigParser } from '../StepsConfigParser';
import { WorkflowHookMetric } from '../StepMetrics';
import { BuildStepRuntimeError } from '../errors';
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from '../errors';
describe('BuildWorkflow hook execution', () => {
let ctx: BuildStepGlobalContext;
let executionLog: string[];
let metrics: WorkflowHookMetric[];
let loggedMessages: string[];
beforeEach(async () => {
executionLog = [];
metrics = [];
loggedMessages = [];
ctx = createGlobalContextMock({
logger: createRecordingLogger(loggedMessages),
reportWorkflowHookMetric: metric => metrics.push(metric),
});
await fs.mkdir(ctx.defaultWorkingDirectory, { recursive: true });
@@ -650,6 +654,44 @@ describe('BuildWorkflow hook execution', () => {
]);
});
it('attributes a broken composite call-site if: to the call, logs it once, and skips the anchor', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{
uses: './.eas/functions/setup',
id: 'setup',
if: '${{ nonexistent.object.property }}',
},
],
},
externalFunctions: [anchorFunction(), recordingFunction('one'), recordingFunction('two')],
compositeFunctionCatalog: {
'./.eas/functions/setup': {
runs: {
steps: [
{ id: 'first', uses: 'test/one' },
{ id: 'second', uses: 'test/two', if: '${{ always() }}' },
],
},
},
},
});
await expect(workflow.executeAsync()).rejects.toThrow(BuildStepConditionEvaluationError);
expect(executionLog).toEqual([]);
expect(hookStepStatuses(workflow)['setup__first']).toBe(BuildStepStatus.SKIPPED);
expect(hookStepStatuses(workflow)['setup__second']).toBe(BuildStepStatus.SKIPPED);
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
expect(metrics).toEqual([]);
const gateMessages = loggedMessages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/setup"');
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
expect(loggedMessages.join('\n')).not.toContain('${{ always() }}');
});
it('skips every step of a composite call whose if condition is false and reports no metric', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
@@ -10,13 +10,18 @@ import {
} from './StepsConfigParser-composite-functions-test-utils';
import { createGlobalContextMock } from './utils/context';
import { getErrorAsync } from './utils/error';
import { createRecordingLogger } from './utils/logger';
import { BuildFunction } from '../BuildFunction';
import { StepsConfigParser } from '../StepsConfigParser';
import { BuildStepStatus } from '../BuildStep';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
import {
BuildConfigError,
BuildStepConditionEvaluationError,
BuildStepRuntimeError,
} from '../errors';
describe('StepsConfigParser local composite functions', () => {
describe('step reference scoping', () => {
@@ -1252,4 +1257,73 @@ describe('StepsConfigParser local composite functions', () => {
expect(show?.getOutputValueByName('out')).toBe('hello');
});
});
describe('call-site if evaluation errors', () => {
it('attributes a broken call-site if: to the composite call and logs it once', async () => {
const messages: string[] = [];
const workflow = await parseCompositeFunctions({
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
catalog: {
[SETUP]: {
runs: {
steps: [
{ id: 'first', uses: 'eas/echo', if: '${{ always() }}' },
{ id: 'second', uses: 'eas/echo' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup', if: '${{ nonexistent.object.property }}' }],
externalFunctions: [echoFunction()],
});
const error = await getErrorAsync(() => workflow.executeAsync());
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.ifCondition).toBe('${{ nonexistent.object.property }}');
for (const id of ['setup__first', 'setup__second']) {
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
}
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain(`composite function call "${SETUP}"`);
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
expect(messages.join('\n')).not.toContain('${{ always() }}');
});
it('names the outer call when its broken if: gates a nested composite, still logging once', async () => {
const messages: string[] = [];
const workflow = await parseCompositeFunctions({
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
catalog: {
'./.eas/functions/outer': {
runs: {
steps: [
{ uses: './.eas/functions/inner', id: 'nested' },
{ id: 'tail', uses: 'eas/echo' },
],
},
},
'./.eas/functions/inner': {
runs: { steps: [{ id: 'leaf', uses: 'eas/echo' }] },
},
},
steps: [
{ uses: './.eas/functions/outer', id: 'top', if: '${{ nonexistent.object.property }}' },
],
externalFunctions: [echoFunction()],
});
const error = await getErrorAsync(() => workflow.executeAsync());
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
for (const id of ['top__nested__leaf', 'top__tail']) {
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
}
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/outer"');
});
});
});
@@ -2,6 +2,7 @@ import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/
import { createGlobalContextMock } from './utils/context';
import { BuildFunction } from '../BuildFunction';
import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
@@ -23,8 +24,9 @@ export async function parseCompositeFunctions(options: {
steps: Step[];
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
ctx?: BuildStepGlobalContext;
}): Promise<BuildWorkflow> {
const ctx = createGlobalContextMock();
const ctx = options.ctx ?? createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
steps: options.steps,
hooks: undefined,
@@ -11,3 +11,20 @@ export function createMockLogger(): bunyan {
} as unknown as bunyan;
return logger;
}
export function createRecordingLogger(sink: string[]): bunyan {
const record = (...args: unknown[]): void => {
const message = args.find(arg => typeof arg === 'string');
if (typeof message === 'string') {
sink.push(message);
}
};
const logger = {
info: jest.fn(record),
debug: jest.fn(record),
error: jest.fn(record),
warn: jest.fn(record),
child: jest.fn(() => logger),
} as unknown as bunyan;
return logger;
}
+12
View File
@@ -23,6 +23,18 @@ export class BuildInternalError extends Error {}
export class BuildStepRuntimeError extends UserError {}
export class BuildStepConditionEvaluationError extends UserError {
constructor(
public readonly subject: string,
public readonly ifCondition: string,
extra?: {
cause?: Error;
}
) {
super(`Failed to evaluate the if condition "${ifCondition}" of ${subject}.`, extra);
}
}
export class BuildWorkflowError extends UserError {
constructor(
public override readonly message: string,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "22.3.0",
"version": "22.4.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "22.3.0",
"@expo/build-tools": "22.4.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "22.0.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.1.0",
"@expo/steps": "22.4.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@hapi/boom": "10.0.1",
Generated
+10 -9
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:22.3.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:22.4.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.1.0"
"@expo/steps": "npm:22.4.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2887,7 +2887,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:22.1.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:22.4.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
@@ -2962,7 +2962,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:22.3.0"
"@expo/build-tools": "npm:22.4.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:22.0.0"
@@ -2971,7 +2971,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.1.0"
"@expo/steps": "npm:22.4.0"
"@expo/template-file": "npm:22.0.0"
"@expo/turtle-spawn": "npm:22.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9727,7 +9727,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:22.1.0"
"@expo/steps": "npm:22.4.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10300,7 +10300,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:22.3.0"
"@expo/build-tools": "npm:22.4.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"
@@ -10374,7 +10374,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:22.1.0"
"@expo/steps": "npm:22.4.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -10451,6 +10451,7 @@ __metadata:
node-forge: "npm:1.4.0"
node-stream-zip: "npm:1.15.0"
nullthrows: "npm:1.1.1"
open: "npm:8.4.0"
ora: "npm:5.1.0"
pkg-dir: "npm:4.2.0"
pngjs: "npm:7.0.0"
@@ -15833,7 +15834,7 @@ __metadata:
languageName: node
linkType: hard
"open@npm:^8.0.4, open@npm:^8.4.0":
"open@npm:8.4.0, open@npm:^8.0.4, open@npm:^8.4.0":
version: 8.4.0
resolution: "open@npm:8.4.0"
dependencies: