Compare commits

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

* [eas-cli] add simulator launch options changelog

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

* [eas-cli] rename simulator open URL flag

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

* [build-tools] add simulator launch options changelog

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

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

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

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

## How

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

## Test plan

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

* [steps] Simplify condition evaluation error handling by building the erro message in the error constructor
2026-08-24 18:52:39 +02:00
Expo CI 70066fc8be v22.3.0 2026-08-24 14:45:57 +00:00
Szymon Dziedzic d27691adef [eas-cli] Add simulator build source flags (#4223)
* [eas-cli] Add simulator build source flags

* [eas-cli] align simulator application archive input

* [eas-cli] add simulator build flags changelog

* [eas-cli] add Expo Go simulator source

* [eas-cli] rename Expo Go simulator flag

* [eas-cli] rename application archive URL flag

* [eas-cli] parse Expo Go download output
2026-08-24 16:42:15 +02:00
Stanisław Chmiela 97d64f4054 [build-tools] Separate iOS ccache by destination (#4238)
# Why

Looks like ccache cache built for iOS simulator is not helpful for iOS device builds and vice versa.

Context: https://exponent-internal.slack.com/archives/C08PBDW7ZC3/p1787242902697509?thread_ts=1787171142.251299&cid=C08PBDW7ZC3

# How

Separated `ios-device-ccache-` and `ios-simulator-ccache-` ccaches.

# Test Plan

- Before: the [simulator build](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/5b9eccae-58a7-4a20-919e-61fbbd8ab68d) restored the [device build’s](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/026d0b35-0a6d-418d-bf34-488ed68f7d19) cache, got 0 hits, and could not save its own cache because the key already existed.
- After: the [device](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/02279218-8450-4679-b377-aec998dff0dd) and [simulator](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/0e6892fd-a9ee-4a90-ba23-97c7a17ad1e9) builds used separate keys. The simulator build got 48 direct hits.
2026-08-24 15:59:02 +02:00
Szymon Dziedzic 1fe8f24313 [build-tools] Install builds in simulator sessions (#4222)
* [build-tools] Install builds in simulator sessions

* [build-tools] split simulator app setup functions

* [build-tools] add simulator app setup changelog

* [build-tools] decouple application launch from artifact

* [build-tools] simplify build source validation

* [build-tools] enforce build source input type

* [build-tools] address simulator install review feedback
2026-08-24 15:08:14 +02:00
Gabe Debes 790231ea18 [build-tools] Support pinning the serve-sim version in a simulator session (#4253) 2026-08-21 17:18:48 -07:00
Gabriel Donadel Dall'Agnol 1f24d00ee9 [build-tools] Add ios_signing_backend option to the repack step (#4239)
* [build-tools] Add ios_signing_backend option to the repack step

* Update CHANGELOG.md

* split out a dedicated resolver for resolveIosSigningOptionsAsync
2026-08-21 19:58:25 +02:00
Douglas LowderandClaude Opus 4.8 3f8ffac11c [build-tools] Submit IPAs to the App Store Connect platform from the bundle (#4234)
eas submit uploaded every IPA to the iOS train because the build upload
hardcoded platform 'IOS', so tvOS binaries were validated against iOS rules
and rejected (ITMS-90508/90545/90713/90039), and their build numbers collided
with the iOS train.

Read DTPlatformName from the IPA in upload_to_asc, map it to the App Store
Connect platform (appletvos -> TV_OS, macosx -> MAC_OS, xros -> VISION_OS,
otherwise IOS), and pass it to createBuildUploadAsync and the TestFlight URL.
This also makes the duplicate-build-number precheck run against the correct
platform train. Falls back to IOS when the platform cannot be read.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-20 15:04:47 -07:00
Expo CI 137bee4db0 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-20 16:22:16 +00:00
77 changed files with 4218 additions and 380 deletions

No files matched your search

+19
View File
@@ -8,8 +8,26 @@ 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))
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 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
### 🎉 New features
- [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
@@ -17,6 +35,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "22.0.0",
"STAGING": "22.2.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.2.0",
"version": "22.4.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.2.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",
@@ -54,6 +54,7 @@
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"content-disposition": "1.0.1",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
@@ -77,7 +78,8 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.6.1",
"@expo/repack-app": "~0.9.0",
"@types/content-disposition": "0.5.9",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
+2 -2
View File
@@ -98,7 +98,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform },
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -238,7 +238,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform },
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
+2 -2
View File
@@ -103,7 +103,7 @@ async function buildInnerAsync(
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
env: ctx.env,
secrets: ctx.job.secrets,
});
@@ -245,7 +245,7 @@ async function buildInnerAsync(
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
target: { platform: ctx.job.platform, simulator: ctx.job.simulator === true },
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
@@ -0,0 +1,46 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { getXcodeVersionAsync } from '../xcode';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(getXcodeVersionAsync, () => {
it('returns the Xcode version', async () => {
mockedSpawn.mockResolvedValue({
stdout: 'Xcode 26.0\nBuild version 17A324\n',
} as any);
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
stdio: 'pipe',
env: process.env,
});
});
it('throws when the Xcode version cannot be parsed', async () => {
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
'Failed to determine Xcode version'
);
});
it('wraps errors from xcodebuild in a SystemError', async () => {
const cause = new Error('xcode-select: error: invalid developer directory');
mockedSpawn.mockRejectedValue(cause);
const promise = getXcodeVersionAsync({ env: process.env });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: 'Failed to get Xcode version',
cause,
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
let stdout: string;
try {
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
} catch (error) {
throw new SystemError('Failed to get Xcode version', { cause: error });
}
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
if (!version) {
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
}
return version;
}
@@ -19,8 +19,10 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
@@ -74,6 +76,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -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({
@@ -51,6 +56,57 @@ function createMockGraphqlClient({
} as unknown as Client;
}
function createSuccessfulResponse({
body,
url,
headers,
}: {
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
);
return {
ok: true,
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
},
} 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();
@@ -58,11 +114,12 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -82,17 +139,39 @@ 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({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: applicationArchiveUrl,
} as unknown as Response);
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -106,6 +185,128 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('downloads a direct application archive URL without querying GraphQL or forwarding the EAS token', async () => {
const applicationArchiveUrl = `https://artifacts.example.test/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient,
robotAccessToken: 'scoped-eas-token',
extensions: ['apk'],
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
applicationArchiveUrl,
expect.objectContaining({ headers: undefined })
);
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the Content-Disposition filename for an extensionless URL', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/download';
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: Buffer.from('hello'),
url: applicationArchiveUrl,
headers: { 'content-disposition': 'attachment; filename="app.apk"' },
})
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('app.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('uses the expected extension when the URL has no filename', async () => {
const applicationArchiveUrl = 'https://artifacts.example.test/';
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: applicationArchiveUrl })
);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
});
expect(path.basename(artifactPath)).toBe('application.apk');
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass buildId or applicationArchiveUrl.',
});
await expect(
// @ts-expect-error Verify the runtime guard for callers without TypeScript.
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
message: 'Pass only one of buildId or applicationArchiveUrl.',
});
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects a non-HTTP application archive URL', async () => {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: 'file:///tmp/app.apk',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['apk'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
message: 'application_archive_url must be a valid HTTP or HTTPS URL.',
});
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
@@ -203,11 +404,12 @@ describe('downloadBuild', () => {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
jest.mocked(fetch).mockResolvedValue(
createSuccessfulResponse({
body: APP_TAR_GZ_BUFFER,
url: APPLICATION_ARCHIVE_URL,
})
);
await expect(
downloadBuildAsync({
@@ -0,0 +1,158 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createInstallBuildFunction, installBuildAsync } from '../installBuild';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const temporaryDirectories: string[] = [];
async function makeTemporaryDirectoryAsync(): Promise<string> {
const temporaryDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-build-test-')
);
temporaryDirectories.push(temporaryDirectory);
return temporaryDirectory;
}
describe(installBuildAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
afterAll(async () => {
await Promise.all(
temporaryDirectories.map(temporaryDirectory =>
fs.promises.rm(temporaryDirectory, { recursive: true, force: true })
)
);
});
it('installs an iOS Simulator .app', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'Example.app');
await fs.promises.mkdir(artifactPath);
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({ stdout: 'com.example.app\n', stderr: '' } as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
[
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
{ stdio: 'pipe', env: {} },
],
['xcrun', ['simctl', 'install', 'booted', artifactPath], { env: {}, logger }],
]);
});
it('installs an Android Emulator .apk', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
});
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it.each([
[BuildRuntimePlatform.DARWIN, 'example.ipa'],
[BuildRuntimePlatform.LINUX, 'example.aab'],
])('rejects an incompatible %s artifact', async (runtimePlatform, filename) => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, filename);
await fs.promises.writeFile(artifactPath, 'artifact');
await expect(
installBuildAsync({
artifactPath,
runtimePlatform,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('installs an Android artifact without a launchable activity', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
const logger = createMockLogger();
mockedSpawn.mockResolvedValue({
stdout: "package: name='com.example.app'\n",
stderr: '',
} as any);
const result = await installBuildAsync({
artifactPath,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(result).toEqual({ applicationIdentifier: 'com.example.app' });
expect(mockedSpawn.mock.calls).toEqual([
['aapt2', ['dump', 'badging', artifactPath], { stdio: 'pipe', env: {} }],
['adb', ['install', '-r', artifactPath], { env: {}, logger }],
]);
});
it('exposes the installed application identifier and activity as step outputs', async () => {
const temporaryDirectory = await makeTemporaryDirectoryAsync();
const artifactPath = path.join(temporaryDirectory, 'example.apk');
await fs.promises.writeFile(artifactPath, 'apk');
mockedSpawn.mockResolvedValueOnce({
stdout:
"package: name='com.example.app'\nlaunchable-activity: name='com.example.app.MainActivity'\n",
stderr: '',
} as any);
const installBuild = createInstallBuildFunction();
const buildStep = installBuild.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{ callInputs: { artifact_path: artifactPath } }
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_identifier.value).toBe('com.example.app');
expect(buildStep.outputById.activity_name.value).toBe('com.example.app.MainActivity');
});
});
@@ -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 });
}
});
});
@@ -0,0 +1,251 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
describe(launchApplicationAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
it('launches an iOS Simulator application by bundle identifier', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app'],
{ env: {}, logger }
);
});
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();
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
{ env: {}, logger }
);
});
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({
applicationIdentifier: 'com.example.app',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--ez', 'isTest', 'true'],
open_url: 'exp://example.test',
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'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'])(
'reports an empty %s input as a user error',
async inputName => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
[inputName]: '',
},
}
);
await expect(buildStep.executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
message: `Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`,
});
expect(mockedSpawn).not.toHaveBeenCalled();
}
);
});
@@ -26,6 +26,7 @@ describe(readIpaInfoAsync, () => {
bundleIdentifier: 'dev.expo.SmallestAppExample',
bundleShortVersion: '1.0',
bundleVersion: '1',
dtPlatformName: 'iphoneos',
});
});
@@ -1,4 +1,5 @@
import { type bunyan } from '@expo/logger';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
@@ -193,22 +194,53 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).not.toBeNull();
expect(signingOptions?.keychainPath).toEqual('/tmp/ios_keychain');
expect(signingOptions?.signingIdentity).toEqual('Test App Certificate');
expect(signingOptions?.provisioningProfile).toEqual({
'com.example.testapp': '/tmp/ios_provisioning_profile',
expect(signingOptions).toMatchObject({
keychainPath: '/tmp/ios_keychain',
signingIdentity: 'Test App Certificate',
provisioningProfile: {
'com.example.testapp': '/tmp/ios_provisioning_profile',
},
});
});
it('should resolve zsign signing options without touching the keychain', async () => {
const job = createTestIosJob();
const tmpDir = '/tmp';
vol.mkdirSync(tmpDir, { recursive: true });
const credentialsManagerSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
backend: 'zsign',
tmpDir,
});
expect(credentialsManagerSpy).not.toHaveBeenCalled();
expect(signingOptions).toMatchObject({
backend: 'zsign',
keyPassword: job.secrets?.buildCredentials?.['testapp'].distributionCertificate.password,
});
assert(signingOptions?.backend === 'zsign');
expect(signingOptions.certificatePath).toMatch(/\/tmp\/dist-cert-.*\.p12/);
expect(vol.existsSync(signingOptions.certificatePath)).toBe(true);
assert(typeof signingOptions.provisioningProfile === 'object');
const profilePath = signingOptions.provisioningProfile['testapp'];
expect(profilePath).toMatch(/\/tmp\/profile-testapp-.*\.mobileprovision/);
expect(vol.existsSync(profilePath)).toBe(true);
});
it('should return undefined if no build credentials are provided', async () => {
// @ts-expect-error: createTestIosJob does not support buildCredentials as null
const job = createTestIosJob({ buildCredentials: null });
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -219,6 +251,7 @@ describe(resolveIosSigningOptionsAsync, () => {
const signingOptions = await resolveIosSigningOptionsAsync({
job,
logger: mockLogger,
tmpDir: '/tmp',
});
expect(signingOptions).toBeUndefined();
});
@@ -8,15 +8,19 @@ import {
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
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';
@@ -26,6 +30,10 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
type DownloadBuildSource =
| { buildId: string; applicationArchiveUrl?: never }
| { buildId?: never; applicationArchiveUrl: string };
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
@@ -49,7 +57,12 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
inputProviders: [
BuildStepInput.createProvider({
id: 'build_id',
required: true,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
@@ -70,12 +83,39 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
const extensions = z.array(z.string()).parse(inputs.extensions.value);
logger.info(`Expected extensions: [${extensions.join(', ')}]`);
const buildId = z.string().uuid().parse(inputs.build_id.value);
logger.info(`Downloading build ${buildId}...`);
const buildId = inputs.build_id.value
? z.string().uuid().parse(inputs.build_id.value)
: undefined;
const applicationArchiveUrl = inputs.application_archive_url.value
? parseHttpApplicationArchiveUrl(inputs.application_archive_url.value)
: undefined;
let source: DownloadBuildSource;
if (buildId) {
if (applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
source = { buildId };
} else {
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass build_id or application_archive_url.'
);
}
source = { applicationArchiveUrl };
}
logger.info(
buildId ? `Downloading build ${buildId}...` : `Downloading application archive...`
);
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
...source,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
@@ -115,39 +155,50 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
if (params.applicationArchiveUrl) {
if (params.buildId) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of buildId or applicationArchiveUrl.'
);
}
downloadUrl = parseHttpApplicationArchiveUrl(params.applicationArchiveUrl);
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass buildId or applicationArchiveUrl.'
);
}
const downloadDestinationDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
// URL may contain percent-encoded characters, e.g. my%20file.apk
// this replaces all non-alphanumeric characters (excluding dot) with underscore
const archiveFilename = path
.basename(new URL(response.url).pathname)
.replace(/([^a-z0-9.-]+)/gi, '_');
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
await streamPipeline(response.body, fs.createWriteStream(archivePath));
@@ -177,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(
@@ -189,8 +252,68 @@ 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);
const parsedUrl = new URL(applicationArchiveUrl);
assert(parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:');
return applicationArchiveUrl;
} catch {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_APPLICATION_ARCHIVE_URL',
'application_archive_url must be a valid HTTP or HTTPS URL.'
);
}
}
function resolveArchiveFilename({
response,
extensions,
}: {
response: Awaited<ReturnType<typeof fetch>>;
extensions: string[];
}): string {
const contentDispositionHeader = response.headers.get('content-disposition');
let headerFilename: string | undefined;
if (contentDispositionHeader) {
try {
headerFilename = contentDisposition.parse(contentDispositionHeader).parameters.filename;
} catch {
// Ignore malformed Content-Disposition headers and fall back to the response URL.
}
}
const urlFilename = path.basename(new URL(response.url).pathname);
let archiveFilename = path.basename(headerFilename ?? urlFilename ?? '');
if (!archiveFilename || archiveFilename === '.' || archiveFilename === '..') {
archiveFilename = 'application';
}
if (!path.extname(archiveFilename) && extensions.length === 1) {
archiveFilename = `${archiveFilename}.${extensions[0]}`;
}
// URL and header filenames may contain percent-encoded or unsafe filesystem characters.
return archiveFilename.replace(/([^a-z0-9.-]+)/gi, '_');
}
@@ -0,0 +1,124 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
export function createInstallBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_build',
name: 'Install build',
__metricsId: 'eas/install_build',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'application_identifier',
required: true,
}),
BuildStepOutput.createProvider({
id: 'activity_name',
required: false,
}),
],
fn: async ({ global, logger }, { inputs, outputs, env }) => {
const artifactPath = z.string().min(1).parse(inputs.artifact_path.value);
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform: global.runtimePlatform,
env,
logger,
});
outputs.application_identifier.set(applicationIdentifier);
if (activityName) {
outputs.activity_name.set(activityName);
}
},
});
}
export async function installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger,
}: {
artifactPath: string;
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{ applicationIdentifier: string; activityName?: string }> {
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
`Build artifact does not exist at ${artifactPath}.`,
{ cause: err }
);
});
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'iOS Simulator sessions require a .app build artifact.'
);
}
const infoPlistPath = path.join(artifactPath, 'Info.plist');
const { stdout } = await spawn(
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
{ stdio: 'pipe', env }
);
const applicationIdentifier = stdout.trim();
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
);
}
logger.info(`Installing ${artifactPath} on the iOS Simulator.`);
await spawn('xcrun', ['simctl', 'install', 'booted', artifactPath], { env, logger });
return { applicationIdentifier };
}
if (path.extname(artifactPath) !== '.apk' || !artifactStat.isFile()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'Android Emulator sessions require an .apk build artifact.'
);
}
const { stdout } = await spawn('aapt2', ['dump', 'badging', artifactPath], {
stdio: 'pipe',
env,
});
const applicationIdentifier = stdout.match(/package: name='([^']+)'/)?.[1];
const activityName = stdout.match(/launchable-activity: name='([^']+)'/)?.[1];
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read an Android application identifier from ${artifactPath}.`
);
}
logger.info(`Installing ${artifactPath} on the Android Emulator.`);
await spawn('adb', ['install', '-r', artifactPath], { env, logger });
return { applicationIdentifier, activityName };
}
@@ -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({
@@ -0,0 +1,175 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
export function createLaunchApplicationFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'launch_application',
name: 'Launch application',
__metricsId: 'eas/launch_application',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_identifier',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'activity_name',
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(
inputs.application_identifier.value,
'application_identifier'
);
const activityName =
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,
});
},
});
}
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) {
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;
}
if (!activityName) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_MISSING_ACTIVITY',
'Launching an Android application requires activity_name.'
);
}
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 {
if (typeof value !== 'string' || value.length === 0) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Input "${inputName}" must be a non-empty string. Pass the "${inputName}" output from eas/install_build.`
);
}
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;
}
@@ -16,6 +16,10 @@ export type IpaInfo = {
bundleIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
// `DTPlatformName` from the bundle's Info.plist (e.g. `iphoneos`, `appletvos`,
// `macosx`, `xros`), or null when absent. Used to pick the App Store Connect
// platform for the upload. See `AscApiUtils.ascPlatformFromDtPlatformName`.
dtPlatformName: string | null;
};
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
@@ -91,10 +95,14 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
);
}
const dtPlatformName =
typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
return {
bundleIdentifier,
bundleShortVersion,
bundleVersion,
dtPlatformName,
};
} catch (error) {
if (error instanceof UserError) {
@@ -62,6 +62,12 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_backend',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
allowedValues: ['fastlane', 'zsign'],
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -144,12 +150,14 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
backend: inputs.ios_signing_backend.value as 'fastlane' | 'zsign' | undefined,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
tmpDir,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -297,24 +305,51 @@ export async function resolveAndroidSigningOptionsAsync({
}
/**
* Resolves iOS signing options from the job secrets.
* Resolves iOS signing options from the job secrets, dispatching on the
* requested signing backend.
*/
export async function resolveIosSigningOptionsAsync({
job,
logger,
backend,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
job: Job;
logger: bunyan;
backend?: 'fastlane' | 'zsign';
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
if (iosJob.simulator || buildCredentials == null) {
return undefined;
}
const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
return backend === 'zsign'
? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
: await createIosFastlaneOptionsAsync(commonOptions);
}
/**
* Creates signing options for the fastlane backend: certificates are imported
* into a temporary keychain and provisioning profiles are parsed with the
* macOS `security` tool.
*/
async function createIosFastlaneOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions> {
const credentialsManager = new IosCredentialsManager(buildCredentials);
const credentials = await credentialsManager.prepare(logger);
@@ -330,3 +365,53 @@ export async function resolveIosSigningOptionsAsync({
entitlementsPath,
};
}
/**
* Creates signing options for the zsign backend. The distribution certificate
* secret is already a PKCS#12 file, so it goes to disk as-is together with the
* provisioning profiles.
*/
async function createIosZsignOptionsAsync({
buildCredentials,
logger,
useAppEntitlements,
entitlementsPath,
tmpDir,
}: {
buildCredentials: Ios.BuildCredentials;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
tmpDir: string;
}): Promise<IosSigningOptions> {
const targets = Object.entries(buildCredentials);
const [targetName, targetCredentials] = targets[0];
logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
const certificatePath = path.join(tmpDir, `dist-cert-${randomUUID()}.p12`);
await fs.promises.writeFile(
certificatePath,
new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64'))
);
// zsign matches profiles to bundles by the app-id suffix itself, so the
// record keys are informational only.
const provisioningProfile: Record<string, string> = {};
for (const [target, credentials] of targets) {
const profilePath = path.join(tmpDir, `profile-${target}-${randomUUID()}.mobileprovision`);
await fs.promises.writeFile(
profilePath,
new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64'))
);
provisioningProfile[target] = profilePath;
}
return {
backend: 'zsign',
certificatePath,
keyPassword: targetCredentials.distributionCertificate.password,
provisioningProfile,
useAppEntitlements,
entitlementsPath,
};
}
@@ -15,8 +15,9 @@ import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
import {
CACHE_KEY_PREFIX_BY_PLATFORM,
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcacheKeyPrefix,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
@@ -35,6 +36,11 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -48,10 +54,20 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
@@ -92,13 +108,13 @@ export function createCacheStatsBuildFunction(): BuildFunction {
export async function restoreCcacheAsync({
logger,
workingDirectory,
platform,
target,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
@@ -136,7 +152,7 @@ export async function restoreCcacheAsync({
})
);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Restoring cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -147,8 +163,8 @@ export async function restoreCcacheAsync({
robotAccessToken,
paths: [cachePath],
key: cacheKey,
keyPrefixes: [CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
platform,
keyPrefixes: [getCcacheKeyPrefix(target)],
platform: target.platform,
});
await decompressCacheAsync({
@@ -175,7 +191,7 @@ export async function restoreCcacheAsync({
expoApiServerURL,
robotAccessToken,
paths: [cachePath],
platform,
platform: target.platform,
});
await decompressCacheAsync({
archivePath,
@@ -15,7 +15,11 @@ import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
@@ -30,6 +34,11 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
const { logger } = stepCtx;
@@ -43,10 +52,20 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
const target: CcacheBuildTarget =
platform === Platform.IOS
? {
platform,
simulator:
(inputs.simulator.value as boolean | undefined) ??
(stepCtx.global.staticContext.job.platform === Platform.IOS &&
stepCtx.global.staticContext.job.simulator === true),
}
: { platform };
await saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
@@ -67,14 +86,14 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
export async function saveCcacheAsync({
logger,
workingDirectory,
platform,
target,
evictUsedBefore,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
platform: Platform;
target: CcacheBuildTarget;
evictUsedBefore: Date;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
@@ -99,7 +118,7 @@ export async function saveCcacheAsync({
}
try {
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, platform);
const cacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, target);
logger.info(`Saving cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
@@ -143,7 +162,7 @@ export async function saveCcacheAsync({
key: cacheKey,
paths: [cachePath],
size,
platform,
platform: target.platform,
});
} catch (err) {
logger.error({ err }, 'Failed to save cache');
@@ -27,6 +27,11 @@ export function createStartServeSimRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -37,6 +42,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
logger.info('Starting serve-sim remote session.');
@@ -47,6 +53,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
@@ -115,16 +115,27 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
// Derive the App Store Connect platform from the IPA itself so tvOS (and
// other) binaries are uploaded to the correct version train instead of the
// iOS one. Falls back to `IOS` if the platform cannot be read.
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const platform = ipaInfoResult.ok
? AscApiUtils.ascPlatformFromDtPlatformName(ipaInfoResult.value.dtPlatformName)
: 'IOS';
stepsCtx.logger.info(`Detected App Store Connect platform: ${platform}`);
stepsCtx.logger.info('Creating Build Upload...');
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
});
const buildUploadId = buildUploadResponse.data.id;
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/ios/${buildUploadId}`;
const platformPathSegment = AscApiUtils.testFlightPlatformPathSegment(platform);
const buildUploadUrl = `https://appstoreconnect.apple.com/apps/${appleAppIdentifier}/testflight/${platformPathSegment}/${buildUploadId}`;
outputs.build_upload_id.set(buildUploadId);
outputs.build_upload_url.set(buildUploadUrl);
@@ -157,6 +157,20 @@ describe(createServeSimArgs, () => {
'https://expo.dev',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
]);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
@@ -272,6 +272,10 @@ export type AscApiClientPostApi = {
};
};
// https://developer.apple.com/documentation/appstoreconnectapi/platform
export type AscPlatform =
AscApiClientPostApi['/v1/buildUploads']['request']['data']['attributes']['platform'];
export type AscApiClientPatchApi = {
[Path in keyof typeof PatchApi]: {
request: z.input<(typeof PatchApi)[Path]['request']>;
@@ -5,9 +5,44 @@ import {
AscApiClientGetApi,
AscApiClientPostApi,
AscApiRequestError,
AscPlatform,
} from './AscApiClient';
export namespace AscApiUtils {
/**
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
* Connect platform used for a build upload. Unknown or missing values fall
* back to `IOS` to preserve the previous default.
*/
export function ascPlatformFromDtPlatformName(dtPlatformName: string | null): AscPlatform {
switch (dtPlatformName) {
case 'appletvos':
return 'TV_OS';
case 'macosx':
return 'MAC_OS';
case 'xros':
return 'VISION_OS';
case 'iphoneos':
default:
return 'IOS';
}
}
/** The App Store Connect TestFlight URL path segment for a platform. */
export function testFlightPlatformPathSegment(platform: AscPlatform): string {
switch (platform) {
case 'TV_OS':
return 'tvos';
case 'MAC_OS':
return 'macos';
case 'VISION_OS':
return 'visionos';
case 'IOS':
default:
return 'ios';
}
}
export async function getAppInfoAsync({
client,
appleAppIdentifier,
@@ -61,18 +96,20 @@ export namespace AscApiUtils {
appleAppIdentifier,
bundleShortVersion,
bundleVersion,
platform,
}: {
client: Pick<AscApiClient, 'postAsync'>;
appleAppIdentifier: string;
bundleShortVersion: string;
bundleVersion: string;
platform: AscPlatform;
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
try {
return await client.postAsync('/v1/buildUploads', {
data: {
type: 'buildUploads',
attributes: {
platform: 'IOS',
platform,
cfBundleShortVersionString: bundleShortVersion,
cfBundleVersion: bundleVersion,
},
@@ -148,6 +148,7 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toEqual(
expect.objectContaining({
@@ -176,10 +177,40 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).resolves.toEqual(response);
});
it('sends the requested platform in the build upload attributes', async () => {
const response = {
data: {
type: 'buildUploads',
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
},
} as const;
const client = {
postAsync: jest.fn().mockResolvedValue(response),
};
await AscApiUtils.createBuildUploadAsync({
client,
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'TV_OS',
});
expect(client.postAsync).toHaveBeenCalledWith(
'/v1/buildUploads',
expect.objectContaining({
data: expect.objectContaining({
attributes: expect.objectContaining({ platform: 'TV_OS' }),
}),
})
);
});
it('rethrows when error payload includes mixed error codes', async () => {
const payload = {
errors: [
@@ -208,8 +239,32 @@ describe('AscApiUtils', () => {
appleAppIdentifier: '1491144534',
bundleShortVersion: '1.2.3',
bundleVersion: '42',
platform: 'IOS',
})
).rejects.toBe(mixedError);
});
});
describe('ascPlatformFromDtPlatformName', () => {
it('maps known DTPlatformName values to App Store Connect platforms', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName('iphoneos')).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('appletvos')).toBe('TV_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('macosx')).toBe('MAC_OS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('xros')).toBe('VISION_OS');
});
it('falls back to IOS for unknown or missing values', () => {
expect(AscApiUtils.ascPlatformFromDtPlatformName(null)).toBe('IOS');
expect(AscApiUtils.ascPlatformFromDtPlatformName('watchos')).toBe('IOS');
});
});
describe('testFlightPlatformPathSegment', () => {
it('returns the TestFlight URL segment for each platform', () => {
expect(AscApiUtils.testFlightPlatformPathSegment('IOS')).toBe('ios');
expect(AscApiUtils.testFlightPlatformPathSegment('TV_OS')).toBe('tvos');
expect(AscApiUtils.testFlightPlatformPathSegment('MAC_OS')).toBe('macos');
expect(AscApiUtils.testFlightPlatformPathSegment('VISION_OS')).toBe('visionos');
});
});
});
@@ -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 ?? '';
@@ -19,7 +19,7 @@ import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@latest';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
@@ -596,18 +596,24 @@ export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
return args;
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
SERVE_SIM_PACKAGE_SPEC,
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
'--host',
@@ -700,20 +706,24 @@ export async function startServeSimWithTunnelAsync(
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
logger.info(
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
);
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const serveSim = spawnDetached({
command: 'npx',
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
env,
});
@@ -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,18 @@
import { Platform } from '@expo/eas-build-job';
import { getCcacheKeyPrefix } from '../cacheKey';
describe(getCcacheKeyPrefix, () => {
it('uses separate prefixes for iOS device and simulator builds', () => {
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: false })).toBe(
'ios-device-ccache-'
);
expect(getCcacheKeyPrefix({ platform: Platform.IOS, simulator: true })).toBe(
'ios-simulator-ccache-'
);
});
it('does not change the Android prefix', () => {
expect(getCcacheKeyPrefix({ platform: Platform.ANDROID })).toBe('android-ccache-');
});
});
@@ -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');
});
});
+14 -7
View File
@@ -7,17 +7,24 @@ import path from 'path';
import { findPackagerRootDir } from './packageManager';
const IOS_CACHE_KEY_PREFIX = 'ios-ccache-';
const IOS_DEVICE_CACHE_KEY_PREFIX = 'ios-device-ccache-';
const IOS_SIMULATOR_CACHE_KEY_PREFIX = 'ios-simulator-ccache-';
const ANDROID_CACHE_KEY_PREFIX = 'android-ccache-';
const PUBLIC_IOS_CACHE_KEY_PREFIX = 'public-ios-ccache-';
const PUBLIC_ANDROID_CACHE_KEY_PREFIX = 'public-android-ccache-';
const DARWIN_CACHE_PATH = 'Library/Caches/ccache';
const LINUX_CACHE_PATH = '.cache/ccache';
export const CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: ANDROID_CACHE_KEY_PREFIX,
[Platform.IOS]: IOS_CACHE_KEY_PREFIX,
};
export type CcacheBuildTarget =
| { platform: Platform.ANDROID }
| { platform: Platform.IOS; simulator: boolean };
export function getCcacheKeyPrefix(target: CcacheBuildTarget): string {
if (target.platform === Platform.IOS) {
return target.simulator ? IOS_SIMULATOR_CACHE_KEY_PREFIX : IOS_DEVICE_CACHE_KEY_PREFIX;
}
return ANDROID_CACHE_KEY_PREFIX;
}
export const PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM: Record<Platform, string> = {
[Platform.ANDROID]: PUBLIC_ANDROID_CACHE_KEY_PREFIX,
@@ -36,7 +43,7 @@ export function getCcachePath(env: Record<string, string | undefined>): string {
export async function generateDefaultBuildCacheKeyAsync(
workingDirectory: string,
platform: Platform
target: CcacheBuildTarget
): Promise<string> {
// This will resolve which package manager and use the relevant lock file
// The lock file hash is the key and ensures cache is fresh
@@ -45,7 +52,7 @@ export async function generateDefaultBuildCacheKeyAsync(
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${CACHE_KEY_PREFIX_BY_PLATFORM[platform]}${hashFiles([lockPath])}`;
return `${getCcacheKeyPrefix(target)}${hashFiles([lockPath])}`;
} catch (err: any) {
throw new Error(`Failed to read lockfile for cache key generation: ${err.message}`);
}
@@ -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",
+280 -199
View File
File diff suppressed because it is too large. Load diff
+80
View File
@@ -37211,6 +37211,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "applicationArchiveUrl",
"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",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "buildId",
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl and expoGo.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceIdentifier",
"description": "Identifier of the virtual device to start for the session. On iOS this is a\nSimulator device name or UDID (e.g. \"iPhone 16 Pro\"). On Android this is an\nAVD hardware profile id (e.g. \"pixel_7\"). If omitted, the runner picks a\ndefault device.",
@@ -37223,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.",
@@ -37259,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\".",
@@ -37287,6 +37355,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sdkVersion",
"description": "Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is\ntrue. If omitted, the current Expo Go archive for the platform is used.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.2.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"
},
@@ -1,17 +1,17 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import Go, { toRepackTargetSdkVersion } from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync, toRepackTargetSdkVersion } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
@@ -15,12 +15,21 @@ const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> =
linux: 'Linux',
};
export type AnalyticsEvent = CommandEvent | BuildEvent | SubmissionEvent | MetadataEvent;
export type AnalyticsEvent =
| CommandEvent
| BuildEvent
| SubmissionEvent
| MetadataEvent
| BillingEvent;
export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
export enum BillingEvent {
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
}
export enum SubmissionEvent {
SUBMIT_COMMAND = 'submit cli submit command',
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
@@ -0,0 +1,49 @@
import { ApiV2Client } from '../../api';
import { BillingClient } from '../billingClient';
jest.mock('../../api');
describe(BillingClient, () => {
const postAsync = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(ApiV2Client).mockImplementation(() => ({ postAsync }) as unknown as ApiV2Client);
});
it('unwraps the apiv2 `data` envelope for a checkout session', async () => {
postAsync.mockResolvedValue({
data: {
id: 'cs_123',
clientSecret: null,
url: 'https://checkout.stripe.com/pay',
},
});
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
const session = await client.createCheckoutSessionAsync('account-id', 'STARTER');
expect(session).toEqual({
id: 'cs_123',
clientSecret: null,
url: 'https://checkout.stripe.com/pay',
});
expect(postAsync).toHaveBeenCalledWith('stripe-auth/checkout', {
body: { accountId: 'account-id', planType: 'STARTER' },
});
});
it('unwraps the apiv2 `data` envelope for a customer portal session', async () => {
postAsync.mockResolvedValue({
data: { customerPortal: { url: 'https://billing.stripe.com' } },
});
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
const session = await client.createCustomerPortalSessionAsync('account-id');
expect(session).toEqual({ url: 'https://billing.stripe.com' });
expect(postAsync).toHaveBeenCalledWith('stripe-auth/customer-portal', {
body: { accountId: 'account-id' },
});
});
});
@@ -0,0 +1,96 @@
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Permission, Role } from '../../graphql/generated';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import { Actor } from '../../user/User';
import { resolveBillingAccountAsync } from '../resolveAccount';
jest.mock('../../graphql/queries/AccountQuery');
describe(resolveBillingAccountAsync, () => {
const graphqlClient = {} as ExpoGraphqlClient;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
});
function account(name: string, role: Role): Actor['accounts'][number] {
return {
id: `${name}-id`,
name,
viewerUserPermission: { role },
} as Actor['accounts'][number];
}
function actor(accounts: Actor['accounts']): Actor {
return { id: 'actor-id', accounts } as Actor;
}
async function resolveAsync(
accounts: Actor['accounts'],
accountName?: string
): ReturnType<typeof resolveBillingAccountAsync> {
return await resolveBillingAccountAsync({
graphqlClient,
actor: actor(accounts),
accountName,
nonInteractive: true,
subscriptionFilter: 'unsubscribed',
});
}
it('automatically selects the only account with billing permission', async () => {
await expect(
resolveAsync([account('viewer', Role.ViewOnly), account('admin', Role.Admin)])
).resolves.toEqual({ id: 'admin-id', name: 'admin', subscription: null });
});
it.each([
['account owners', Role.Owner],
['custom roles with admin permission', Role.HasAdmin],
])('allows %s to manage billing', async (_case, role) => {
await expect(resolveAsync([account('team', role)])).resolves.toEqual({
id: 'team-id',
name: 'team',
subscription: null,
});
});
it('allows the owner of a personal account to manage billing', async () => {
const ownerAccount = {
...account('personal', Role.ViewOnly),
ownerUserActor: { __typename: 'User', id: 'actor-id', username: 'personal' },
} as Actor['accounts'][number];
await expect(resolveAsync([ownerAccount])).resolves.toEqual({
id: 'personal-id',
name: 'personal',
subscription: null,
});
});
it('rejects an explicitly selected account without billing permission', async () => {
await expect(resolveAsync([account('developer', Role.Developer)], 'developer')).rejects.toThrow(
'You must be an Owner or Admin of account "developer" to manage billing.'
);
expect(AccountQuery.getByNameAsync).not.toHaveBeenCalled();
});
it('rejects an account lookup without billing permission', async () => {
jest.mocked(AccountQuery.getByNameAsync).mockResolvedValue({
id: 'viewer-id',
name: 'viewer',
viewerUserPermission: { id: 'viewer-permission-id', permissions: [Permission.View] },
});
await expect(resolveAsync([], 'viewer')).rejects.toThrow(
'You must be an Owner or Admin of account "viewer" to manage billing.'
);
});
it('fails when no account has billing permission', async () => {
await expect(resolveAsync([account('viewer', Role.ViewOnly)])).rejects.toThrow(
'You must be an Owner or Admin of at least one account to manage billing.'
);
});
});
@@ -0,0 +1,37 @@
import { ApiV2Client } from '../api';
import type { CheckoutPlanType } from './plans';
export type CheckoutSession = {
id: string;
clientSecret: string | null;
url: string | null;
};
export type CustomerPortalSession = {
url: string;
};
export class BillingClient {
private readonly apiV2Client: ApiV2Client;
constructor(authInfo: { accessToken: string | null; sessionSecret: string | null }) {
this.apiV2Client = new ApiV2Client(authInfo);
}
async createCheckoutSessionAsync(
accountId: string,
planType: CheckoutPlanType
): Promise<CheckoutSession> {
const { data } = await this.apiV2Client.postAsync('stripe-auth/checkout', {
body: { accountId, planType },
});
return data;
}
async createCustomerPortalSessionAsync(accountId: string): Promise<CustomerPortalSession> {
const { data } = await this.apiV2Client.postAsync('stripe-auth/customer-portal', {
body: { accountId },
});
return data.customerPortal;
}
}
+30
View File
@@ -0,0 +1,30 @@
import Log, { link } from '../log';
import { ora } from '../ora';
import openBrowser = require('open');
export async function openOrPrintUrlAsync(
url: string,
{ label, open }: { label: string; open: boolean }
): Promise<void> {
if (!open) {
Log.log(`${label}: ${link(url)}`);
return;
}
const spinner = ora(`Opening ${label.toLowerCase()}`).start();
let opened = false;
try {
await openBrowser(url);
opened = true;
} catch (error) {
Log.debug(`Failed to open a web browser: ${error}`);
}
if (opened) {
spinner.succeed(`Opened ${label.toLowerCase()} in your browser`);
} else {
spinner.fail('Unable to open a web browser automatically.');
}
Log.log(`${label}: ${link(url)}`);
}
+23
View File
@@ -0,0 +1,23 @@
export const SUBSCRIBABLE_PLANS = {
starter: { planType: 'STARTER', label: 'Starter' },
production: { planType: 'PRODUCTION', label: 'Production' },
} as const;
export type PlanSlug = keyof typeof SUBSCRIBABLE_PLANS;
export type CheckoutPlanType = (typeof SUBSCRIBABLE_PLANS)[PlanSlug]['planType'];
export const PLAN_SLUGS = Object.keys(SUBSCRIBABLE_PLANS) as PlanSlug[];
export const FREE_PLAN_PRICE_ID = 'price_free';
export function formatStarterSubscribeCommand(accountName?: string): string {
return `eas billing:subscribe starter${accountName ? ` --account ${accountName}` : ''}`;
}
export function hasPaidSubscription(
subscription: {
planId?: string | null;
} | null
): boolean {
return subscription?.planId != null && subscription.planId !== FREE_PLAN_PRICE_ID;
}
@@ -0,0 +1,151 @@
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Permission, Role } from '../graphql/generated';
import { AccountQuery, type AccountSubscriptionInfo } from '../graphql/queries/AccountQuery';
import { hasPaidSubscription } from './plans';
import { Actor } from '../user/User';
import { ExpoChoice, selectAsync } from '../prompts';
export type BillingAccount = {
id: string;
name: string;
subscription: AccountSubscriptionInfo | null;
};
type SubscriptionFilter = 'subscribed' | 'unsubscribed';
function billingPermissionError(accountName: string): Error {
return new Error(`You must be an Owner or Admin of account "${accountName}" to manage billing.`);
}
function hasBillingRole(actor: Actor, account: Actor['accounts'][number]): boolean {
if (account.ownerUserActor?.id === actor.id) {
return true;
}
const { role } = account.viewerUserPermission;
return role === Role.Admin || role === Role.Owner || role === Role.HasAdmin;
}
async function getAccountWithSubscriptionAsync(
graphqlClient: ExpoGraphqlClient,
account: { id: string; name: string }
): Promise<BillingAccount> {
const subscription = await AccountQuery.getSubscriptionAsync(graphqlClient, account.id);
return { id: account.id, name: account.name, subscription };
}
function toAccountChoice(account: BillingAccount, disabled: boolean): ExpoChoice<BillingAccount> {
const fallback = hasPaidSubscription(account.subscription) ? 'Paid' : 'Free';
return {
title: account.name,
value: account,
description: `Current plan: ${account.subscription?.name ?? fallback}`,
disabled,
};
}
async function resolveNamedAccountAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
accountName: string
): Promise<{ id: string; name: string }> {
const ownAccount = actor.accounts.find(account => account.name === accountName);
if (ownAccount) {
if (!hasBillingRole(actor, ownAccount)) {
throw billingPermissionError(ownAccount.name);
}
return ownAccount;
}
const account = await AccountQuery.getByNameAsync(graphqlClient, accountName).catch(() => null);
if (!account) {
const availableAccounts = actor.accounts
.filter(candidate => hasBillingRole(actor, candidate))
.map(candidate => candidate.name)
.join(', ');
throw new Error(
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
);
}
const { permissions } = account.viewerUserPermission;
if (!permissions.includes(Permission.Admin) && !permissions.includes(Permission.Own)) {
throw billingPermissionError(account.name);
}
return account;
}
export async function resolveBillingAccountAsync({
graphqlClient,
actor,
accountName,
nonInteractive,
subscriptionFilter,
}: {
graphqlClient: ExpoGraphqlClient;
actor: Actor;
accountName: string | undefined;
nonInteractive: boolean;
subscriptionFilter: SubscriptionFilter;
}): Promise<BillingAccount> {
if (accountName) {
const namedAccount = await resolveNamedAccountAsync(graphqlClient, actor, accountName);
const account = await getAccountWithSubscriptionAsync(graphqlClient, namedAccount);
if (subscriptionFilter === 'subscribed' && !hasPaidSubscription(account.subscription)) {
throw new Error(
`Account "${account.name}" does not have an active paid plan. Run eas billing:subscribe to subscribe.`
);
}
return account;
}
const billingAccounts = actor.accounts.filter(account => hasBillingRole(actor, account));
if (billingAccounts.length === 0) {
throw new Error('You must be an Owner or Admin of at least one account to manage billing.');
}
const accounts = await Promise.all(
billingAccounts.map(account => getAccountWithSubscriptionAsync(graphqlClient, account))
);
if (subscriptionFilter === 'unsubscribed' && accounts.length === 1) {
return accounts[0];
}
const subscribedAccounts = accounts.filter(account => hasPaidSubscription(account.subscription));
const unsubscribedAccounts = accounts.filter(
account => !hasPaidSubscription(account.subscription)
);
const eligibleAccounts =
subscriptionFilter === 'subscribed' ? subscribedAccounts : unsubscribedAccounts;
if (eligibleAccounts.length === 0) {
throw new Error(
subscriptionFilter === 'subscribed'
? 'No available accounts have an active paid plan. Run eas billing:subscribe to subscribe an account.'
: 'All available accounts already have a paid plan. Run eas billing:manage to change an existing subscription.'
);
}
if (eligibleAccounts.length === 1) {
return eligibleAccounts[0];
}
if (nonInteractive) {
throw new Error(
'The --account flag must be provided when running in `--non-interactive` mode and you can manage billing for more than one eligible account.'
);
}
const disabledAccounts = subscriptionFilter === 'unsubscribed' ? subscribedAccounts : [];
return await selectAsync(
'Select an account:',
[
...eligibleAccounts.map(account => toAccountChoice(account, false)),
...disabledAccounts.map(account => toAccountChoice(account, true)),
],
{
initial: eligibleAccounts[0],
warningMessageForDisabledEntries:
'This account already has a paid plan. Run eas billing:manage to change it.',
}
);
}
@@ -295,39 +295,41 @@ describe(Build.name, () => {
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
() => {
handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform, accountName);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1'
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildFreeTierIosLimitExceededError>(() => {
handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform, accountName);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
'Error 1'
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
});
+16 -3
View File
@@ -43,6 +43,7 @@ import {
import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { createFingerprintAsync } from '../fingerprint/cli';
@@ -209,7 +210,7 @@ export async function prepareBuildRequestForPlatformAsync<
});
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
handleBuildRequestError(error, job.platform, ctx.accountName);
}
} else {
throw new Error('Unknown localBuildMode.');
@@ -230,7 +231,11 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
VALIDATION_ERROR: RequestValidationError,
};
export function handleBuildRequestError(error: any, platform: Platform): never {
export function handleBuildRequestError(
error: any,
platform: Platform,
accountName?: string
): never {
logBuildRequestErrorDebugInfo(error);
const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors)
@@ -241,7 +246,15 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
| undefined;
if (graphQLErrorCode && graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
throw new ErrorClass(graphQLErrors[0]?.message);
const message = graphQLErrors[0]?.message;
const isFreeTierLimitError =
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
throw new ErrorClass(
isFreeTierLimitError
? `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`
: message
);
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
throw new EasBuildDownForMaintenanceError(
`EAS Build is down for maintenance. Try again later. Check ${link(
@@ -0,0 +1,163 @@
import open from 'open';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { BillingClient } from '../../../billing/billingClient';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { printJsonOnlyOutput } from '../../../utils/json';
import BillingManage from '../manage';
jest.mock('open');
jest.mock('../../../billing/billingClient');
jest.mock('../../../graphql/queries/AccountQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
jest.mock('../../../ora');
function billingAccount(
id: string,
name: string
): { id: string; name: string; viewerUserPermission: { role: Role } } {
return { id, name, viewerUserPermission: { role: Role.Admin } };
}
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
describe(BillingManage, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const account = billingAccount('account-id', 'testaccount');
const createCustomerPortalSessionAsync = jest.fn();
function createCommand(
argv: string[],
accounts: ReturnType<typeof billingAccount>[] = [account]
): BillingManage {
const command = new BillingManage(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
loggedIn: {
graphqlClient,
actor: { id: 'actor-id', accounts },
authenticationInfo: { accessToken: 'token', sessionSecret: null },
},
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
jest
.mocked(BillingClient)
.mockImplementation(() => ({ createCustomerPortalSessionAsync }) as unknown as BillingClient);
});
it('creates a customer portal session and prints the URL as JSON', async () => {
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session/abc',
});
await createCommand(['--json']).runAsync();
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('account-id');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
customerPortalUrl: 'https://billing.stripe.com/session/abc',
});
});
it('opens the customer portal in a browser when interactive, without re-encoding the URL', async () => {
const url = 'https://billing.stripe.com/session/abc%2Fdef';
createCustomerPortalSessionAsync.mockResolvedValue({ url });
jest.mocked(open).mockResolvedValue({} as never);
await createCommand([]).runAsync();
expect(open).toHaveBeenCalledWith(url);
});
it('prints the customer portal URL without opening a browser with --no-open', async () => {
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session/abc',
});
await createCommand(['--no-open']).runAsync();
expect(open).not.toHaveBeenCalled();
});
it('only shows accounts with active paid plans', async () => {
const freeAccount = billingAccount('free-account-id', 'free-account');
const productionAccount = billingAccount('production-account-id', 'production-account');
jest.mocked(AccountQuery.getSubscriptionAsync).mockImplementation(async (_client, accountId) =>
accountId === freeAccount.id
? null
: {
id: `subscription-${accountId}`,
name: accountId === productionAccount.id ? 'Production' : 'Starter',
planId: `price-${accountId}`,
}
);
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[1].value);
createCustomerPortalSessionAsync.mockResolvedValue({
url: 'https://billing.stripe.com/session',
});
await createCommand([], [account, freeAccount, productionAccount]).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select an account:',
[
{
title: 'testaccount',
value: {
id: 'account-id',
name: 'testaccount',
subscription: {
id: 'subscription-account-id',
name: 'Starter',
planId: 'price-account-id',
},
},
description: 'Current plan: Starter',
disabled: false,
},
{
title: 'production-account',
value: {
id: 'production-account-id',
name: 'production-account',
subscription: {
id: 'subscription-production-account-id',
name: 'Production',
planId: 'price-production-account-id',
},
},
description: 'Current plan: Production',
disabled: false,
},
],
expect.any(Object)
);
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('production-account-id');
});
it('rejects an account without an active paid plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
await expect(createCommand(['--account', 'testaccount']).runAsync()).rejects.toThrow(
'Account "testaccount" does not have an active paid plan. Run eas billing:subscribe to subscribe.'
);
expect(createCustomerPortalSessionAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,308 @@
import open from 'open';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { BillingClient } from '../../../billing/billingClient';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../../../graphql/generated';
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { selectAsync } from '../../../prompts';
import { printJsonOnlyOutput } from '../../../utils/json';
import BillingSubscribe from '../subscribe';
jest.mock('open');
jest.mock('../../../billing/billingClient');
jest.mock('../../../graphql/queries/AccountQuery');
jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/json');
jest.mock('../../../ora');
function billingAccount(
id: string,
name: string
): { id: string; name: string; viewerUserPermission: { role: Role } } {
return { id, name, viewerUserPermission: { role: Role.Admin } };
}
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
const FREE_SUBSCRIPTION = { id: 'sub_free', name: 'Free', planId: 'price_free' };
describe(BillingSubscribe, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const account = billingAccount('account-id', 'testaccount');
const createCheckoutSessionAsync = jest.fn();
const logEvent = jest.fn();
function createCommand(
argv: string[],
accounts: ReturnType<typeof billingAccount>[] = [account]
): BillingSubscribe {
const command = new BillingSubscribe(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
analytics: { logEvent },
loggedIn: {
graphqlClient,
actor: { id: 'actor-id', accounts },
authenticationInfo: { accessToken: 'token', sessionSecret: null },
},
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
jest.mocked(ora).mockReturnValue({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
} as any);
jest
.mocked(BillingClient)
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
});
it('creates a checkout session for a plan and prints the URL as JSON', async () => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs_123',
url: 'https://checkout.stripe.com/c/pay/cs_123',
});
await createCommand(['starter', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_123',
alreadySubscribed: false,
});
});
it('maps each plan slug to its server PlanType', async () => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['production', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
});
it('prompts for a plan when one is not provided', async () => {
jest.mocked(selectAsync).mockResolvedValue('production');
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith('Select a plan:', [
{ title: 'Starter', value: 'starter' },
{ title: 'Production', value: 'production' },
]);
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
expect(jest.mocked(AccountQuery.getSubscriptionAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(selectAsync).mock.invocationCallOrder[0]
);
});
it('prompts for an account before prompting for a plan', async () => {
jest
.mocked(selectAsync)
.mockImplementationOnce(async (_message, choices) => choices[0].value)
.mockResolvedValueOnce('production');
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(
[],
[account, billingAccount('second-account-id', 'second-account')]
).runAsync();
expect(jest.mocked(selectAsync).mock.calls.map(([message]) => message)).toEqual([
'Select an account:',
'Select a plan:',
]);
});
it('requires a plan in non-interactive mode', async () => {
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'The plan argument is required in non-interactive mode.'
);
expect(selectAsync).not.toHaveBeenCalled();
});
it('shows current plans and disables subscribed accounts in the account prompt', async () => {
const subscribedAccount = billingAccount('subscribed-id', 'subscribed');
const freeAccount = billingAccount('free-id', 'free');
const secondFreeAccount = billingAccount('second-free-id', 'second-free');
jest
.mocked(AccountQuery.getSubscriptionAsync)
.mockImplementation(async (_client, accountId) =>
accountId === subscribedAccount.id ? STARTER_SUBSCRIPTION : null
);
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[0].value);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(
['starter'],
[subscribedAccount, freeAccount, secondFreeAccount]
).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select an account:',
[
{
title: 'free',
value: { id: 'free-id', name: 'free', subscription: null },
description: 'Current plan: Free',
disabled: false,
},
{
title: 'second-free',
value: { id: 'second-free-id', name: 'second-free', subscription: null },
description: 'Current plan: Free',
disabled: false,
},
{
title: 'subscribed',
value: {
id: 'subscribed-id',
name: 'subscribed',
subscription: STARTER_SUBSCRIPTION,
},
description: 'Current plan: Starter',
disabled: true,
},
],
{
initial: { id: 'free-id', name: 'free', subscription: null },
warningMessageForDisabledEntries:
'This account already has a paid plan. Run eas billing:manage to change it.',
}
);
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('free-id', 'STARTER');
});
it.each([
['an explicitly selected account is', ['production', '--account', 'testaccount', '--json']],
['the only account is', ['production', '--json']],
])('returns the current plan when %s already subscribed', async (_case, argv) => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(argv).runAsync();
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
alreadySubscribed: true,
currentPlan: 'Starter',
});
});
it('reports an explicitly selected subscribed account before prompting for a plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(['--account', 'testaccount']).runAsync();
expect(selectAsync).not.toHaveBeenCalled();
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Account testaccount is already subscribed to the Starter plan.'
);
expect(Log.log).toHaveBeenCalledWith('To change or cancel your plan, run eas billing:manage.');
});
it('treats the free plan as not subscribed', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(FREE_SUBSCRIPTION);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['starter', '--json']).runAsync();
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
});
it.each([['--non-interactive'], ['--no-open']])(
'prints the checkout URL without opening a browser with %s',
async flag => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs',
url: 'https://checkout.stripe.com/pay',
});
await createCommand(['starter', flag]).runAsync();
expect(open).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
expect(link).toHaveBeenCalledWith('https://checkout.stripe.com/pay');
}
);
it('returns structured JSON with the checkout URL and does not open a browser', async () => {
createCheckoutSessionAsync.mockResolvedValue({
id: 'cs',
url: 'https://checkout.stripe.com/pay',
});
await createCommand(['starter', '--json']).runAsync();
expect(open).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
checkoutUrl: 'https://checkout.stripe.com/pay',
alreadySubscribed: false,
});
});
it.each([
['checkout', null],
['already subscribed', STARTER_SUBSCRIPTION],
])(
'does not emit null JSON values, which printJsonOnlyOutput strips (%s)',
async (_case, subscription) => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(subscription);
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['starter', '--json']).runAsync();
const [payload] = jest.mocked(printJsonOnlyOutput).mock.calls[0];
expect(Object.values(payload)).not.toContain(null);
}
);
it.each([
['--json', true],
['--no-open', false],
])('reports the subscribe command to analytics (%s)', async (flag, json) => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
await createCommand(['production', flag]).runAsync();
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
account_id: 'account-id',
json,
non_interactive: json,
already_subscribed: false,
plan: 'production',
});
});
it('reports an idempotent subscribe to analytics without a plan', async () => {
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
await createCommand(['production', '--json']).runAsync();
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
account_id: 'account-id',
json: true,
non_interactive: true,
already_subscribed: true,
});
});
it('throws when the checkout session has no URL', async () => {
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: null });
await expect(createCommand(['starter', '--json']).runAsync()).rejects.toThrow(
'The checkout session did not include a URL.'
);
expect(Log.log).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import { Flags } from '@oclif/core';
import { BillingClient } from '../../billing/billingClient';
import { openOrPrintUrlAsync } from '../../billing/openUrl';
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ora } from '../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BillingManage extends EasCommand {
static override description = 'manage billing for an account with an active paid EAS plan';
static override flags = {
account: Flags.string({
char: 'a',
description:
'Account with an active paid plan to manage. Defaults to your account when only one is eligible.',
}),
'no-open': Flags.boolean({
description: 'Only print the customer portal URL instead of opening it in a browser',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BillingManage);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient, actor, authenticationInfo },
} = await this.getContextAsync(BillingManage, { nonInteractive });
const account = await resolveBillingAccountAsync({
graphqlClient,
actor,
accountName: flags.account,
nonInteractive,
subscriptionFilter: 'subscribed',
});
const billingClient = new BillingClient(authenticationInfo);
const spinner = ora(`Creating a customer portal session for ${account.name}`).start();
let portalUrl: string;
try {
const { url } = await billingClient.createCustomerPortalSessionAsync(account.id);
portalUrl = url;
spinner.succeed(`Created a customer portal session for ${account.name}`);
} catch (error) {
spinner.fail('Failed to create a customer portal session');
throw error;
}
if (json) {
printJsonOnlyOutput({ customerPortalUrl: portalUrl });
return;
}
await openOrPrintUrlAsync(portalUrl, {
label: 'Customer portal',
open: !flags['no-open'] && !nonInteractive,
});
}
}
@@ -0,0 +1,150 @@
import { Args, Flags } from '@oclif/core';
import { BillingEvent } from '../../analytics/AnalyticsManager';
import { BillingClient } from '../../billing/billingClient';
import { openOrPrintUrlAsync } from '../../billing/openUrl';
import { PLAN_SLUGS, PlanSlug, SUBSCRIBABLE_PLANS, hasPaidSubscription } from '../../billing/plans';
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { ora } from '../../ora';
import { selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BillingSubscribe extends EasCommand {
static override description = 'subscribe a Free account to an EAS plan';
static override args = {
PLAN: Args.string({
description: 'plan to subscribe to. Required in non-interactive mode.',
required: false,
options: [...PLAN_SLUGS],
}),
};
static override flags = {
account: Flags.string({
char: 'a',
description: 'Account to subscribe. Defaults to your account when you only have one.',
}),
'no-open': Flags.boolean({
description: 'Only print the checkout page URL instead of opening it in a browser',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
const {
args: { PLAN },
flags,
} = await this.parse(BillingSubscribe);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
let planSlug = PLAN as PlanSlug | undefined;
const {
analytics,
loggedIn: { graphqlClient, actor, authenticationInfo },
} = await this.getContextAsync(BillingSubscribe, { nonInteractive });
const account = await resolveBillingAccountAsync({
graphqlClient,
actor,
accountName: flags.account,
nonInteractive,
subscriptionFilter: 'unsubscribed',
});
const analyticsProperties = {
account_id: account.id,
json,
non_interactive: nonInteractive,
};
const { subscription } = account;
if (hasPaidSubscription(subscription)) {
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
...analyticsProperties,
already_subscribed: true,
});
if (json) {
printJsonOnlyOutput({
alreadySubscribed: true,
currentPlan: subscription?.name ?? null,
});
return;
}
Log.warn(
`Account ${account.name} is already subscribed${
subscription?.name ? ` to the ${subscription.name} plan` : ''
}.`
);
Log.log('To change or cancel your plan, run eas billing:manage.');
return;
}
if (!planSlug && nonInteractive) {
throw new Error('The plan argument is required in non-interactive mode.');
}
if (!planSlug) {
planSlug = await selectAsync(
'Select a plan:',
PLAN_SLUGS.map(slug => ({
title: SUBSCRIBABLE_PLANS[slug].label,
value: slug,
}))
);
}
const plan = SUBSCRIBABLE_PLANS[planSlug];
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
...analyticsProperties,
already_subscribed: false,
plan: planSlug,
});
const billingClient = new BillingClient(authenticationInfo);
const spinner = ora(`Creating a checkout session for the ${plan.label} plan`).start();
let checkoutUrl: string;
try {
const session = await billingClient.createCheckoutSessionAsync(account.id, plan.planType);
if (!session.url) {
throw new Error('The checkout session did not include a URL.');
}
checkoutUrl = session.url;
spinner.succeed(`Created a checkout session for the ${plan.label} plan`);
} catch (error) {
spinner.fail('Failed to create a checkout session');
throw error;
}
if (json) {
printJsonOnlyOutput({ checkoutUrl, alreadySubscribed: false });
return;
}
Log.log(
`Complete your subscription to the ${plan.label} plan for ${account.name} in Stripe checkout.`
);
await openOrPrintUrlAsync(checkoutUrl, {
label: 'Checkout page',
open: !flags['no-open'] && !nonInteractive,
});
}
}
+2 -16
View File
@@ -1,4 +1,4 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -30,7 +30,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync, selectAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
@@ -48,20 +48,6 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
export function toRepackTargetSdkVersion(sdkVersion: string | undefined): string | undefined {
const coerced = semver.coerce(sdkVersion);
return coerced ? `${coerced.major}.0.0` : sdkVersion;
@@ -23,6 +23,7 @@ import {
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
import Simulator from '../index';
jest.mock('fs-extra');
@@ -45,6 +46,7 @@ jest.mock('../../../simulator/env', () => ({
loadSimulatorEnvAsync: jest.fn(),
resetSimulatorEnvAsync: jest.fn(),
}));
jest.mock('../../../simulator/expoGo');
jest.mock('../../../prompts');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
@@ -78,6 +80,7 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
const mockOra = jest.mocked(ora);
const mockPromptAsync = jest.mocked(promptAsync);
@@ -161,6 +164,7 @@ describe(Simulator, () => {
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
mockLoadSimulatorEnvAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
});
@@ -498,6 +502,190 @@ describe(Simulator, () => {
);
});
it('forwards --build-id to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--build-id',
' 8d8b713c-1834-4bd3-91e6-46f895422cbc ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ buildId: '8d8b713c-1834-4bd3-91e6-46f895422cbc' })
);
});
it('forwards --application-archive-url to the create mutation', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--application-archive-url',
' https://example.test/builds/app.apk ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/builds/app.apk',
})
);
});
it.each([
['ios', AppPlatform.Ios],
['android', AppPlatform.Android],
] as const)(
'forwards Expo Go and the project SDK for %s when --expo-go is passed',
async (platformFlag, appPlatform) => {
const { command } = createCommand([
'--platform',
platformFlag,
'--non-interactive',
'--expo-go',
]);
await command.runAsync();
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
projectDir,
sdkVersion: undefined,
});
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
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',
'ios',
'--non-interactive',
'--build-id',
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
'--application-archive-url',
'https://example.test/builds/app.tar.gz',
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it.each([
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--expo-go',
sourceFlag,
sourceValue,
]);
await expect(command.runAsync()).rejects.toThrow();
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('stops the simulator session when interrupted before the session is ready', async () => {
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit(${code})`);
@@ -28,6 +28,7 @@ import {
resetSimulatorEnvAsync,
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
@@ -72,6 +73,33 @@ export default class Simulator extends EasCommand {
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
}),
'build-id': Flags.string({
description: 'EAS Build to install and launch before the simulator session is ready.',
exclusive: ['application-archive-url', 'expo-go'],
}),
'application-archive-url': Flags.string({
description:
'Application archive URL to download, install, and launch before the simulator session is ready.',
exclusive: ['build-id', 'expo-go'],
}),
'expo-go': Flags.boolean({
description:
"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),
@@ -139,6 +167,25 @@ export default class Simulator extends EasCommand {
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
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];
@@ -149,6 +196,9 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const expoGoSdkVersion = flags['expo-go']
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
: undefined;
if (existingDeviceRunSessionId) {
Log.warn(
@@ -171,6 +221,13 @@ export default class Simulator extends EasCommand {
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
deviceIdentifier,
...(buildId ? { buildId } : {}),
...(applicationArchiveUrlFromFlag
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
: {}),
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
...(launchArgs?.length ? { launchArgs } : {}),
...(openUrl ? { openUrl } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
+39 -1
View File
@@ -5234,6 +5234,16 @@ export type CreateDeviceRunSessionEventLogUploadSessionResult = {
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 and expoGo.
*/
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
/**
* EAS Build to install and launch before the simulator session becomes available.
* Mutually exclusive with applicationArchiveUrl and expoGo.
*/
buildId?: InputMaybe<Scalars['ID']['input']>;
/**
* Identifier of the virtual device to start for the session. On iOS this is a
* Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android this is an
@@ -5241,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
@@ -5261,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;
};
@@ -14647,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'];
@@ -14675,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;
},
};
@@ -0,0 +1,17 @@
import { getConfigFilePaths } from '@expo/config';
import { getPrivateExpoConfigAsync } from './expoConfig';
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
@@ -0,0 +1,50 @@
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
import { resolveExpoGoSdkVersionAsync } from '../expoGo';
jest.mock('../../project/detectProjectSdkVersionAsync');
const projectDir = '/test/project';
const mockDetectProjectSdkVersionAsync = jest.mocked(detectProjectSdkVersionAsync);
describe(resolveExpoGoSdkVersionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
mockDetectProjectSdkVersionAsync.mockResolvedValue('55.0.0');
});
it('normalizes the detected project SDK version', async () => {
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).resolves.toBe('55.0.0');
expect(mockDetectProjectSdkVersionAsync).toHaveBeenCalledWith(projectDir);
});
it.each([
['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(
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'])(
'rejects an undetectable SDK version (%s)',
async sdkVersion => {
mockDetectProjectSdkVersionAsync.mockResolvedValue(sdkVersion);
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).rejects.toThrow(
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected."
);
}
);
});
+28
View File
@@ -0,0 +1,28 @@
import semver from 'semver';
import { EasCommandError } from '../commandUtils/errors';
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
export async function resolveExpoGoSdkVersionAsync({
projectDir,
sdkVersion: sdkVersionFromFlag,
}: {
projectDir: string;
sdkVersion?: string;
}): Promise<string> {
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.'
);
}
return `${sdkMajorVersion}.0.0`;
}
@@ -462,6 +462,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('Upgrade your plan to continue service.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/));
});
@@ -505,6 +510,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
});
it('at tier: displays a paid-plan limit-reached message with pay-as-you-go warning', () => {
@@ -543,6 +553,11 @@ describe('displayOverageWarning', () => {
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining('New builds are blocked until your billing period resets.')
);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
)
);
});
it('over tier: displays a paid-plan overage count and accrued cost', () => {
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { formatStarterSubscribeCommand } from '../../billing/plans';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import Log, { link } from '../../log';
@@ -136,6 +137,7 @@ export function displayOverageWarning({
overageCostCents: number;
}): void {
const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`;
const upgradeHint = ` Run ${formatStarterSubscribeCommand(name)} to upgrade to the Starter plan.`;
if (tier === 'approaching') {
const percentUsed = calculatePercentUsed(planValue, limit);
@@ -149,7 +151,8 @@ export function displayOverageWarning({
Log.warn(
hasFreePlan
? "You won't be able to start new builds once you reach the limit. " +
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false })
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false }) +
upgradeHint
: 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
@@ -163,7 +166,8 @@ export function displayOverageWarning({
Log.warn(
hasFreePlan
? 'New builds are blocked until your billing period resets. ' +
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false })
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false }) +
upgradeHint
: 'Additional builds will be charged at pay-as-you-go rates. ' +
link(billingUrl, { text: 'See usage in billing.', dim: false })
);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "22.2.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.2.0",
"@expo/build-tools": "22.4.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "22.1.0",
"version": "22.4.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -12,7 +12,7 @@ import type { BuildStepOutputAccessor } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput } from './BuildStepInput';
import { BuildStepRuntimeError } from './errors';
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from './errors';
import {
resolveInterpolatedTarget,
stringifyInterpolatedResult,
@@ -77,7 +77,20 @@ export class BuildStepCompositeFunctionScope {
if (this.parent && !this.parent.isActive(evaluate, runByDefault)) {
return false;
}
this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate, runByDefault);
if (this.cachedIsActive === undefined) {
try {
this.cachedIsActive = this.evaluateCallIfCondition(evaluate, runByDefault);
} catch (err) {
// An unevaluable gate skips the call, cache the skip before throwing.
// Only the first child reports the error, the rest skip silently.
this.cachedIsActive = false;
throw new BuildStepConditionEvaluationError(
`composite function call "${this.compositeFunctionPath}"`,
this.ifCondition ?? '',
{ cause: err instanceof Error ? err : undefined }
);
}
}
return this.cachedIsActive;
}
+6 -3
View File
@@ -7,6 +7,7 @@ import { BuildStep } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { CompositeBuildStep } from './CompositeBuildStep';
import { StepMetricResult } from './StepMetrics';
import { BuildStepConditionEvaluationError } from './errors';
import { AnchorHooks, HookEntry } from './hooks';
import { evaluateIfCondition } from './utils/jsepEval';
@@ -279,15 +280,17 @@ export async function executeHookStepsAsync(
return { failedLocally, firstError };
}
// Shared wording for unevaluable `if:` gates. Callers pass the step's own
// `if:`, but the throw may come from a composite call-site `if:` evaluated via
// the step's scope. In that case the quoted condition is not the one that failed.
// Shared wording for unevaluable `if:` gates.
function logConditionEvaluationError(
logger: bunyan,
err: unknown,
subject: string,
ifCondition: string | undefined
): void {
if (err instanceof BuildStepConditionEvaluationError) {
({ subject, ifCondition } = err);
err = err.cause ?? err;
}
logger.error({ err });
logger.error(
`Runner failed to evaluate if it should execute ${subject}${
@@ -2,16 +2,26 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
import { instance, mock, when } from 'ts-mockito';
import { createGlobalContextMock } from './utils/context';
import { getError } from './utils/error';
import { BuildStep } from '../BuildStep';
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildStepConditionEvaluationError } from '../errors';
import { interpolateJobContext } from '../interpolation';
describe(BuildStepCompositeFunctionScope, () => {
const baseContext = {} as unknown as JobInterpolationContext;
function makeScope(): BuildStepCompositeFunctionScope {
function makeScope({
ifCondition,
parent,
compositeFunctionPath = 'test-action',
}: {
ifCondition?: string;
parent?: BuildStepCompositeFunctionScope;
compositeFunctionPath?: string;
} = {}): BuildStepCompositeFunctionScope {
const ctx = createGlobalContextMock();
const versionOutput = mock<BuildStepOutput>();
@@ -29,7 +39,9 @@ describe(BuildStepCompositeFunctionScope, () => {
greeting.set('hello');
return new BuildStepCompositeFunctionScope({
ctx,
compositeFunctionPath: 'test-action',
parent,
ifCondition,
compositeFunctionPath,
inputs: new Map([['greeting', greeting]]),
providedInputKeys: new Set(['greeting']),
childrenByLocalId: new Map([['build', instance(innerStep)]]),
@@ -69,5 +81,46 @@ describe(BuildStepCompositeFunctionScope, () => {
expect(scope.isActive(neverEvaluate, true)).toBe(true);
expect(scope.isActive(neverEvaluate, false)).toBe(true);
});
it('wraps an evaluation failure with the call as subject, its condition, and the raw cause', () => {
const rawError = new Error('boom');
const scope = makeScope({ ifCondition: '${{ broken }}' });
const error = getError(() =>
scope.isActive(() => {
throw rawError;
}, true)
);
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.subject).toBe('composite function call "test-action"');
expect(error.ifCondition).toBe('${{ broken }}');
expect(error.cause).toBe(rawError);
});
it('reports an evaluation failure once; later calls skip without re-evaluating', () => {
let evaluations = 0;
const evaluate = (): boolean => {
evaluations += 1;
throw new Error('boom');
};
const scope = makeScope({ ifCondition: '${{ broken }}' });
expect(() => scope.isActive(evaluate, true)).toThrow(BuildStepConditionEvaluationError);
expect(scope.isActive(evaluate, true)).toBe(false);
expect(evaluations).toBe(1);
});
it('propagates a parent call-site failure naming the parent scope, not the child', () => {
const parent = makeScope({
ifCondition: '${{ broken }}',
compositeFunctionPath: 'parent-action',
});
const child = makeScope({ parent });
const error = getError(() =>
child.isActive(() => {
throw new Error('boom');
}, true)
);
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.subject).toBe('composite function call "parent-action"');
});
});
});
@@ -4,6 +4,7 @@ import { Hooks, Step } from '@expo/eas-build-job';
import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils';
import { createGlobalContextMock } from './utils/context';
import { createRecordingLogger } from './utils/logger';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepStatus } from '../BuildStep';
@@ -13,17 +14,20 @@ import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { StepsConfigParser } from '../StepsConfigParser';
import { WorkflowHookMetric } from '../StepMetrics';
import { BuildStepRuntimeError } from '../errors';
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from '../errors';
describe('BuildWorkflow hook execution', () => {
let ctx: BuildStepGlobalContext;
let executionLog: string[];
let metrics: WorkflowHookMetric[];
let loggedMessages: string[];
beforeEach(async () => {
executionLog = [];
metrics = [];
loggedMessages = [];
ctx = createGlobalContextMock({
logger: createRecordingLogger(loggedMessages),
reportWorkflowHookMetric: metric => metrics.push(metric),
});
await fs.mkdir(ctx.defaultWorkingDirectory, { recursive: true });
@@ -650,6 +654,44 @@ describe('BuildWorkflow hook execution', () => {
]);
});
it('attributes a broken composite call-site if: to the call, logs it once, and skips the anchor', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
hooks: {
before_install_node_modules: [
{
uses: './.eas/functions/setup',
id: 'setup',
if: '${{ nonexistent.object.property }}',
},
],
},
externalFunctions: [anchorFunction(), recordingFunction('one'), recordingFunction('two')],
compositeFunctionCatalog: {
'./.eas/functions/setup': {
runs: {
steps: [
{ id: 'first', uses: 'test/one' },
{ id: 'second', uses: 'test/two', if: '${{ always() }}' },
],
},
},
},
});
await expect(workflow.executeAsync()).rejects.toThrow(BuildStepConditionEvaluationError);
expect(executionLog).toEqual([]);
expect(hookStepStatuses(workflow)['setup__first']).toBe(BuildStepStatus.SKIPPED);
expect(hookStepStatuses(workflow)['setup__second']).toBe(BuildStepStatus.SKIPPED);
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
expect(metrics).toEqual([]);
const gateMessages = loggedMessages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/setup"');
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
expect(loggedMessages.join('\n')).not.toContain('${{ always() }}');
});
it('skips every step of a composite call whose if condition is false and reports no metric', async () => {
const workflow = await parseAsync({
steps: [{ uses: 'eas/install_node_modules' }],
@@ -10,13 +10,18 @@ import {
} from './StepsConfigParser-composite-functions-test-utils';
import { createGlobalContextMock } from './utils/context';
import { getErrorAsync } from './utils/error';
import { createRecordingLogger } from './utils/logger';
import { BuildFunction } from '../BuildFunction';
import { StepsConfigParser } from '../StepsConfigParser';
import { BuildStepStatus } from '../BuildStep';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
import { BuildWorkflow } from '../BuildWorkflow';
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
import {
BuildConfigError,
BuildStepConditionEvaluationError,
BuildStepRuntimeError,
} from '../errors';
describe('StepsConfigParser local composite functions', () => {
describe('step reference scoping', () => {
@@ -1252,4 +1257,73 @@ describe('StepsConfigParser local composite functions', () => {
expect(show?.getOutputValueByName('out')).toBe('hello');
});
});
describe('call-site if evaluation errors', () => {
it('attributes a broken call-site if: to the composite call and logs it once', async () => {
const messages: string[] = [];
const workflow = await parseCompositeFunctions({
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
catalog: {
[SETUP]: {
runs: {
steps: [
{ id: 'first', uses: 'eas/echo', if: '${{ always() }}' },
{ id: 'second', uses: 'eas/echo' },
],
},
},
},
steps: [{ uses: SETUP, id: 'setup', if: '${{ nonexistent.object.property }}' }],
externalFunctions: [echoFunction()],
});
const error = await getErrorAsync(() => workflow.executeAsync());
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
expect(error.ifCondition).toBe('${{ nonexistent.object.property }}');
for (const id of ['setup__first', 'setup__second']) {
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
}
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain(`composite function call "${SETUP}"`);
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
expect(messages.join('\n')).not.toContain('${{ always() }}');
});
it('names the outer call when its broken if: gates a nested composite, still logging once', async () => {
const messages: string[] = [];
const workflow = await parseCompositeFunctions({
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
catalog: {
'./.eas/functions/outer': {
runs: {
steps: [
{ uses: './.eas/functions/inner', id: 'nested' },
{ id: 'tail', uses: 'eas/echo' },
],
},
},
'./.eas/functions/inner': {
runs: { steps: [{ id: 'leaf', uses: 'eas/echo' }] },
},
},
steps: [
{ uses: './.eas/functions/outer', id: 'top', if: '${{ nonexistent.object.property }}' },
],
externalFunctions: [echoFunction()],
});
const error = await getErrorAsync(() => workflow.executeAsync());
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
for (const id of ['top__nested__leaf', 'top__tail']) {
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
}
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
expect(gateMessages).toHaveLength(1);
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/outer"');
});
});
});
@@ -2,6 +2,7 @@ import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/
import { createGlobalContextMock } from './utils/context';
import { BuildFunction } from '../BuildFunction';
import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
import { BuildStepOutput } from '../BuildStepOutput';
@@ -23,8 +24,9 @@ export async function parseCompositeFunctions(options: {
steps: Step[];
externalFunctions?: BuildFunction[];
externalFunctionGroups?: BuildFunctionGroup[];
ctx?: BuildStepGlobalContext;
}): Promise<BuildWorkflow> {
const ctx = createGlobalContextMock();
const ctx = options.ctx ?? createGlobalContextMock();
const parser = new StepsConfigParser(ctx, {
steps: options.steps,
hooks: undefined,
@@ -11,3 +11,20 @@ export function createMockLogger(): bunyan {
} as unknown as bunyan;
return logger;
}
export function createRecordingLogger(sink: string[]): bunyan {
const record = (...args: unknown[]): void => {
const message = args.find(arg => typeof arg === 'string');
if (typeof message === 'string') {
sink.push(message);
}
};
const logger = {
info: jest.fn(record),
debug: jest.fn(record),
error: jest.fn(record),
warn: jest.fn(record),
child: jest.fn(() => logger),
} as unknown as bunyan;
return logger;
}
+12
View File
@@ -23,6 +23,18 @@ export class BuildInternalError extends Error {}
export class BuildStepRuntimeError extends UserError {}
export class BuildStepConditionEvaluationError extends UserError {
constructor(
public readonly subject: string,
public readonly ifCondition: string,
extra?: {
cause?: Error;
}
) {
super(`Failed to evaluate the if condition "${ifCondition}" of ${subject}.`, extra);
}
}
export class BuildWorkflowError extends UserError {
constructor(
public override readonly message: string,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "22.2.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.2.0",
"@expo/build-tools": "22.4.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "22.0.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "22.1.0",
"@expo/steps": "22.4.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@hapi/boom": "10.0.1",
Generated
+71 -16
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:22.2.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:
@@ -2303,15 +2303,16 @@ __metadata:
"@expo/logger": "npm:22.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.6.1"
"@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"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
"@types/content-disposition": "npm:0.5.9"
"@types/fs-extra": "npm:^11.0.4"
"@types/jest": "npm:^29.5.12"
"@types/lodash": "npm:^4.17.4"
@@ -2326,6 +2327,7 @@ __metadata:
"@urql/core": "npm:^6.0.1"
bplist-parser: "npm:0.3.2"
bunyan: "npm:^1.8.15"
content-disposition: "npm:1.0.1"
fast-glob: "npm:^3.3.2"
fast-xml-parser: "npm:^4.4.1"
fs-extra: "npm:^11.2.0"
@@ -2818,15 +2820,16 @@ __metadata:
languageName: node
linkType: hard
"@expo/repack-app@npm:~0.6.1":
version: 0.6.1
resolution: "@expo/repack-app@npm:0.6.1"
"@expo/repack-app@npm:~0.9.0":
version: 0.9.0
resolution: "@expo/repack-app@npm:0.9.0"
dependencies:
commander: "npm:^14.0.2"
picocolors: "npm:^1.1.1"
zsign-wasm: "npm:^0.0.2"
bin:
repack-app: bin/cli.js
checksum: 10c0/fdf3088b4f327393056631270bf90a67ba949e7994892a62abc985bd9478119ab2f1d5be35fa1efb6bd1d2780c49a3dbb87cb669d9c65b44e3d9a0b41fbf091f
checksum: 10c0/0a493d97b80b98a1a90172baf81d7efee6b65399bf15171aae9c6505be3eedd4e59d05a8a33b3a6b6b720a2a96b7c2c5043c8ec92e390a5f917e2dd139d88aab
languageName: node
linkType: hard
@@ -2884,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:
@@ -2959,7 +2962,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:22.2.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"
@@ -2968,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"
@@ -7084,7 +7087,7 @@ __metadata:
languageName: node
linkType: hard
"@types/content-disposition@npm:*":
"@types/content-disposition@npm:*, @types/content-disposition@npm:0.5.9":
version: 0.5.9
resolution: "@types/content-disposition@npm:0.5.9"
checksum: 10c0/9fd520dae1a9c7b85909e59f548f905a670a6e2f83642f9734d1b05126f64ac8c8e2c282a663edf27d58bc34fbbacf46a4d4a95d3802b42106174a19a68175c8
@@ -9492,7 +9495,7 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:~1.0.1":
"content-disposition@npm:1.0.1, content-disposition@npm:~1.0.1":
version: 1.0.1
resolution: "content-disposition@npm:1.0.1"
checksum: 10c0/bd7ff1fe8d2542d3a2b9a29428cc3591f6ac27bb5595bba2c69664408a68f9538b14cbd92479796ea835b317a09a527c8c7209c4200381dedb0c34d3b658849e
@@ -9724,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"
@@ -10297,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.2.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"
@@ -10371,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"
@@ -10448,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"
@@ -12327,6 +12331,13 @@ __metadata:
languageName: node
linkType: hard
"immediate@npm:~3.0.5":
version: 3.0.6
resolution: "immediate@npm:3.0.6"
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
languageName: node
linkType: hard
"immutable@npm:^5.1.5":
version: 5.1.9
resolution: "immutable@npm:5.1.9"
@@ -13769,6 +13780,18 @@ __metadata:
languageName: node
linkType: hard
"jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
lie: "npm:~3.3.0"
pako: "npm:~1.0.2"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:^1.0.5"
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
languageName: node
linkType: hard
"just-diff-apply@npm:^5.2.0":
version: 5.5.0
resolution: "just-diff-apply@npm:5.5.0"
@@ -14011,6 +14034,15 @@ __metadata:
languageName: node
linkType: hard
"lie@npm:~3.3.0":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"lilconfig@npm:^3.1.3":
version: 3.1.3
resolution: "lilconfig@npm:3.1.3"
@@ -15802,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:
@@ -16196,6 +16228,13 @@ __metadata:
languageName: node
linkType: hard
"pako@npm:~1.0.2":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
languageName: node
linkType: hard
"param-case@npm:^3.0.4":
version: 3.0.4
resolution: "param-case@npm:3.0.4"
@@ -17456,6 +17495,13 @@ __metadata:
languageName: node
linkType: hard
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
@@ -19992,3 +20038,12 @@ __metadata:
checksum: 10c0/5a2db7e59177a3d7e202543f5136cb87b97b047b77c8a3d824098d3fa8b80d3aa40a0a5f296965c3b82dfdccdd05dbbfacce91347f16a39c675680fd7b1ab109
languageName: node
linkType: hard
"zsign-wasm@npm:^0.0.2":
version: 0.0.2
resolution: "zsign-wasm@npm:0.0.2"
dependencies:
jszip: "npm:^3.10.1"
checksum: 10c0/f8919b66031c93970987fa9ac0e742cc7975e4c4c6fbf60fa477e1154109a50a4bf907b75dff8afe72b457df49ce5d060e3a3b93ba3c92b8c453d85206058f22
languageName: node
linkType: hard