mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c39ad4bf | ||
|
|
1ad3822421 | ||
|
|
0929787398 | ||
|
|
756016a433 | ||
|
|
c6e3c332ac | ||
|
|
7c95a6b4b2 | ||
|
|
16d234b11d |
No files matched your search
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.3.0",
|
||||
"version": "22.4.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Generated
+58
-2
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user