mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f74ea369d | ||
|
|
28f80da648 | ||
|
|
a7260dfed3 | ||
|
|
5cd1b5eee3 | ||
|
|
9d627c6917 | ||
|
|
66dbcaf432 | ||
|
|
39c39ad4bf | ||
|
|
1ad3822421 | ||
|
|
0929787398 | ||
|
|
756016a433 | ||
|
|
c6e3c332ac | ||
|
|
7c95a6b4b2 | ||
|
|
16d234b11d |
No files matched your search
@@ -0,0 +1,90 @@
|
||||
name: Update Appium command list
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/update-appium-commands.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Regenerate Appium command list
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Regenerate the Appium command list
|
||||
# The generator installs Appium, XCUITest, and UiAutomator2 on demand,
|
||||
# so it always picks up the latest supported command sets.
|
||||
run: yarn workspace @expo/build-tools generate-appium-commands
|
||||
- name: Check whether the command list changed
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet -- packages/build-tools/src/steps/utils/appiumCommands.generated.ts; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Upload the generated command list
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: generate
|
||||
if: needs.generate.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Appium command list update
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download the generated command list
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils
|
||||
- name: Open a pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
run: |
|
||||
branch=bot/update-appium-commands
|
||||
title='[build-tools] Update Appium command list'
|
||||
body=$'Automated update of the Appium, XCUITest, and UiAutomator2 command list.\n\nIf this PR adds new commands, typechecking will fail until a curated summary is added for each of them in `packages/build-tools/src/steps/utils/appiumCommandSummary.ts`.'
|
||||
|
||||
gh auth setup-git
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git checkout -B "$branch"
|
||||
git add packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
if gh pr view "$branch" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
|
||||
gh pr edit "$branch" --title "$title" --body "$body" --add-reviewer sjchmiela
|
||||
else
|
||||
gh pr create --base main --head "$branch" --title "$title" --body "$body" \
|
||||
--label 'no changelog' --reviewer sjchmiela
|
||||
fi
|
||||
+25
-2
@@ -8,6 +8,30 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
|
||||
|
||||
### 🎉 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))
|
||||
- [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
|
||||
|
||||
- [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))
|
||||
|
||||
## [22.3.0](https://github.com/expo/eas-cli/releases/tag/v22.3.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
@@ -15,7 +39,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
- [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))
|
||||
|
||||
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
|
||||
|
||||
@@ -30,7 +54,6 @@ 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
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.2.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
"STAGING": "22.4.0",
|
||||
"PRODUCTION": "22.4.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.3.0",
|
||||
"version": "22.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.3.0",
|
||||
"version": "22.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,6 +25,7 @@
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -46,7 +47,7 @@
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Regenerates src/steps/utils/appiumCommands.generated.ts from Appium and the
|
||||
// XCUITest and UiAutomator2 drivers, so TypeScript can verify that we handle
|
||||
// every command that can appear in Appium event timings.
|
||||
//
|
||||
// Appium and its drivers are NOT dependencies of this package. Like repack, we
|
||||
// install them on demand into a temp directory just for this script, so nothing
|
||||
// heavy ships with @expo/build-tools.
|
||||
//
|
||||
// Run with: yarn workspace @expo/build-tools generate-appium-commands
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// Track the Appium 3 line, matching the appium@^3 the worker installs for
|
||||
// simulator sessions (see startAppiumRemoteSession.ts).
|
||||
const APPIUM_VERSION = '^3';
|
||||
const DRIVERS = [
|
||||
{
|
||||
packageName: 'appium-xcuitest-driver',
|
||||
exportName: 'XCUITestDriver',
|
||||
typeName: 'XCUITestAppiumCommand',
|
||||
},
|
||||
{
|
||||
packageName: 'appium-uiautomator2-driver',
|
||||
exportName: 'AndroidUiautomator2Driver',
|
||||
typeName: 'UiAutomator2AppiumCommand',
|
||||
},
|
||||
];
|
||||
|
||||
function renderCommandType(typeName, commands) {
|
||||
return `export type ${typeName} =\n${commands.map(command => ` | ${serializeStringLiteral(command)}`).join('\n')};`;
|
||||
}
|
||||
|
||||
function serializeStringLiteral(value) {
|
||||
const jsonContents = JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"');
|
||||
return `'${jsonContents.replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function readRouteCommands(methodMap) {
|
||||
return Object.values(methodMap).flatMap(route =>
|
||||
Object.values(route).map(method => method.command)
|
||||
);
|
||||
}
|
||||
|
||||
async function readDriverCommands({ packageName, exportName }, sandbox) {
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [sandbox] });
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const modulePath = path.join(path.dirname(packageJsonPath), packageJson.exports['.'].import);
|
||||
const driverModule = await import(pathToFileURL(modulePath).href);
|
||||
const Driver = driverModule[exportName];
|
||||
return [
|
||||
...readRouteCommands(Driver.newMethodMap),
|
||||
...Object.values(Driver.executeMethodMap).map(method => method.command),
|
||||
];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'appium-command-generator-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox, 'package.json'),
|
||||
`${JSON.stringify({ name: 'appium-command-generator-sandbox', private: true })}\n`
|
||||
);
|
||||
execFileSync(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
sandbox,
|
||||
`appium@${APPIUM_VERSION}`,
|
||||
...DRIVERS.map(({ packageName }) => packageName),
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { ALL_COMMANDS } = require(require.resolve('@appium/base-driver', { paths: [sandbox] }));
|
||||
const baseDriverCommands = [...new Set(ALL_COMMANDS)].sort();
|
||||
const baseDriverCommandSet = new Set(baseDriverCommands);
|
||||
const driverCommands = await Promise.all(
|
||||
DRIVERS.map(async driver => ({
|
||||
typeName: driver.typeName,
|
||||
commands: [...new Set(await readDriverCommands(driver, sandbox))]
|
||||
.filter(command => !baseDriverCommandSet.has(command))
|
||||
.sort(),
|
||||
}))
|
||||
);
|
||||
const commands = [
|
||||
...new Set([...baseDriverCommands, ...driverCommands.flatMap(({ commands }) => commands)]),
|
||||
];
|
||||
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'steps',
|
||||
'utils',
|
||||
'appiumCommands.generated.ts'
|
||||
);
|
||||
const contents = `// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run \`yarn workspace @expo/build-tools generate-appium-commands\` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
${renderCommandType('BaseDriverAppiumCommand', baseDriverCommands)}
|
||||
|
||||
${driverCommands
|
||||
.map(({ typeName, commands: commandsForDriver }) =>
|
||||
renderCommandType(typeName, commandsForDriver)
|
||||
)
|
||||
.join('\n\n')}
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
`;
|
||||
fs.writeFileSync(outputPath, contents);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Wrote ${commands.length} Appium commands to ${outputPath}`);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { getXcodeVersionAsync } from '../xcode';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe(getXcodeVersionAsync, () => {
|
||||
it('returns the Xcode version', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: 'Xcode 26.0\nBuild version 17A324\n',
|
||||
} as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).resolves.toBe('26.0');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcodebuild', ['-version'], {
|
||||
stdio: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the Xcode version cannot be parsed', async () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'unexpected output' } as any);
|
||||
|
||||
await expect(getXcodeVersionAsync({ env: process.env })).rejects.toThrow(
|
||||
'Failed to determine Xcode version'
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps errors from xcodebuild in a SystemError', async () => {
|
||||
const cause = new Error('xcode-select: error: invalid developer directory');
|
||||
mockedSpawn.mockRejectedValue(cause);
|
||||
|
||||
const promise = getXcodeVersionAsync({ env: process.env });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Failed to get Xcode version',
|
||||
cause,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export async function getXcodeVersionAsync({ env }: { env: NodeJS.ProcessEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
}
|
||||
const version = /^Xcode\s+(\d+(?:\.\d+)*)$/m.exec(stdout)?.[1];
|
||||
if (!version) {
|
||||
throw new SystemError(`Failed to determine Xcode version from: ${stdout.trim()}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -19,6 +21,9 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const FLAT_APP_INFO_PLIST_BASE64 =
|
||||
'YnBsaXN0MDDSAQIDBF8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QE0NGQnVuZGxlUGFja2FnZVR5cGVXVGVzdEFwcFRBUFBMCA0iOEAAAAAAAAABAQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAARQ==';
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
@@ -74,6 +79,34 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'Info.plist'), FLAT_APP_INFO_PLIST_BASE64, {
|
||||
encoding: 'base64',
|
||||
}),
|
||||
fs.promises.writeFile(path.join(sourceDirectory, 'TestApp'), 'i am executable\n'),
|
||||
]);
|
||||
await tar.create(
|
||||
{
|
||||
cwd: sourceDirectory,
|
||||
file: archivePath,
|
||||
gzip: true,
|
||||
},
|
||||
['Info.plist', 'TestApp']
|
||||
);
|
||||
return await fs.promises.readFile(archivePath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(sourceDirectory, { recursive: true, force: true }),
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -106,6 +139,28 @@ describe('downloadBuild', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads an archive containing app bundle contents at its root', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
createSuccessfulResponse({
|
||||
body: await createFlatAppTarGzBufferAsync(),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
})
|
||||
);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(path.extname(artifactPath)).toBe('.app');
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
@@ -11,6 +15,11 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/downloader', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
@@ -18,10 +27,24 @@ jest.mock('../../../datadog', () => ({
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
async function writeInstalledWdaVersion(homeDirectory: string, version: string): Promise<void> {
|
||||
const wdaDirectory = path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'drivers',
|
||||
'ios',
|
||||
'WebDriverAgent'
|
||||
);
|
||||
await fs.promises.mkdir(wdaDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(wdaDirectory, 'package.json'), JSON.stringify({ version }));
|
||||
}
|
||||
|
||||
describe('createInstallMaestroBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
// `maestro --version` reports an installed version; `java -version` succeeds.
|
||||
// With no requested version and Maestro present, the step skips installation.
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
@@ -439,4 +462,184 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build', HOME: '/home/expo' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
// The installed WDA version is unknown (no package.json under $HOME), so the
|
||||
// cache download is skipped before resolving the Xcode/runtime versions.
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
});
|
||||
|
||||
it('continues when the prebuilt WDA cache is not available', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
isAvailable: true,
|
||||
version: '26.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
mockedDownloadFile.mockRejectedValue(new Error('HTTP 404'));
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
]);
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,101 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on iOS', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['--uitesting', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'host.exp.Exponent', '--uitesting', 'true'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
'com.apple.launchservices.schemeapproval',
|
||||
'com.apple.CoreSimulator.CoreSimulatorBridge-->exp',
|
||||
'-string',
|
||||
'host.exp.Exponent',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a web URL on iOS without preapproving its scheme', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
openUrl: 'https://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'https://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens a custom URL on iOS when scheme preapproval fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const approvalError = new Error('Scheme approval is unavailable.');
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockRejectedValueOnce(approvalError);
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: approvalError },
|
||||
'Could not preapprove the exp URL scheme for host.exp.Exponent. Opening the URL anyway; the Simulator might require manual confirmation.'
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an Android Emulator application by package and activity', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -53,6 +148,56 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes launch arguments and opens a URL on Android', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.exponent',
|
||||
activityName: 'host.exp.exponent.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Launching host.exp.exponent with arguments ["--ez","isTest","true"].'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an activity when launching an Android application', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
@@ -74,6 +219,8 @@ describe(launchApplicationAsync, () => {
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--ez', 'isTest', 'true'],
|
||||
open_url: 'exp://example.test',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -81,9 +228,71 @@ describe(launchApplicationAsync, () => {
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-n', 'com.example.app/com.example.app.MainActivity'],
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--ez',
|
||||
'isTest',
|
||||
'true',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
launch_args: launchArgs,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "launch_args" must be an array of strings.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid URL to open', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
open_url: 'not a URL',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
message: 'Input "open_url" must be a valid URL.',
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['application_identifier', 'activity_name'])(
|
||||
|
||||
@@ -19,6 +19,8 @@ import path from 'node:path';
|
||||
import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
@@ -226,6 +228,18 @@ export async function downloadBuildAsync(
|
||||
onlyFiles: false,
|
||||
onlyDirectories: false,
|
||||
});
|
||||
let matchingFilesRoot = extractionDirectory;
|
||||
|
||||
if (
|
||||
matchingFiles.length === 0 &&
|
||||
extensions.includes('app') &&
|
||||
(await isIosAppBundleAsync(extractionDirectory))
|
||||
) {
|
||||
const appBundlePath = `${extractionDirectory}.app`;
|
||||
await fs.promises.rename(extractionDirectory, appBundlePath);
|
||||
matchingFiles.push(appBundlePath);
|
||||
matchingFilesRoot = path.dirname(appBundlePath);
|
||||
}
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
throw new UserError(
|
||||
@@ -238,12 +252,28 @@ export async function downloadBuildAsync(
|
||||
`Found ${matchingFiles.length} matching ${pluralize(
|
||||
matchingFiles.length,
|
||||
'entry'
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(extractionDirectory, f)}`).join('\n')}`
|
||||
)}:\n${matchingFiles.map(f => `- ${path.relative(matchingFilesRoot, f)}`).join('\n')}`
|
||||
);
|
||||
|
||||
return { artifactPath: matchingFiles[0] };
|
||||
}
|
||||
|
||||
async function isIosAppBundleAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
const infoPlist = await fs.promises.readFile(path.join(directory, 'Info.plist'));
|
||||
const isBinaryPlist = infoPlist.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
const parsedInfoPlist = (
|
||||
isBinaryPlist
|
||||
? bplistParser.parseBuffer(infoPlist)[0]
|
||||
: plist.parse(infoPlist.toString('utf8'))
|
||||
) as Record<string, unknown> | undefined;
|
||||
|
||||
return parsedInfoPlist?.CFBundlePackageType === 'APPL';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHttpApplicationArchiveUrl(value: unknown): string {
|
||||
try {
|
||||
const applicationArchiveUrl = z.string().parse(value);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
@@ -19,6 +20,11 @@ import semver from 'semver';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { getXcodeVersionAsync } from '../../ios/xcode';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { getProxiedDownloadUrl } from '../../utils/download';
|
||||
|
||||
const MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -148,11 +154,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
const xcodeVersion = await getXcodeVersion({ env });
|
||||
// `getXcodeVersionAsync` returns the raw version (e.g. "16.4"), which
|
||||
// `semver.coerce` normalizes (e.g. "16.4.0") for comparison and display.
|
||||
const xcodeVersion = semver.coerce(await getXcodeVersionAsync({ env }));
|
||||
|
||||
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
|
||||
// below 26 reject that option, so use the last compatible maestro-runner version.
|
||||
if (semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (xcodeVersion && semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
@@ -190,6 +198,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
throw new Error(`Failed to ensure ${backend} is installed.`);
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
@@ -201,19 +216,140 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
async function getXcodeVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
async function installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
if (!env.HOME) {
|
||||
logger.warn(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the $HOME environment variable is empty.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maestroRunnerHome = path.join(env.HOME, '.maestro-runner');
|
||||
|
||||
const xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
|
||||
if (!xcodeVersion) {
|
||||
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
|
||||
try {
|
||||
const wdaVersion = await getMaestroRunnerWdaVersion({ maestroRunnerHome });
|
||||
if (!wdaVersion) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because the installed WDA version is unknown.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const xcodeVersion = await getXcodeVersionAsync({ env });
|
||||
const iosRuntimes = await IosSimulatorUtils.getAvailableRuntimesAsync({ env });
|
||||
const iosRuntimeIdentifierPrefix = 'com.apple.CoreSimulator.SimRuntime.iOS-';
|
||||
// maestro-runner derives its WDA cache key from the runtime identifier, not from the runtime
|
||||
// version. These values can differ for patched runtimes, for example the iOS-26-3 identifier
|
||||
// can report version 26.3.1.
|
||||
const iosRuntimeVersions = [
|
||||
...new Set(
|
||||
iosRuntimes.map(runtime =>
|
||||
runtime.identifier.slice(iosRuntimeIdentifierPrefix.length).replaceAll('-', '.')
|
||||
)
|
||||
),
|
||||
];
|
||||
if (iosRuntimeVersions.length === 0) {
|
||||
logger.info(
|
||||
'Skipping the prebuilt WebDriverAgent cache because no iOS runtime is available.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install_maestro_runner_wda_cache')
|
||||
);
|
||||
try {
|
||||
const archivePath = path.join(tempDirectory, 'wda-cache.tar.gz');
|
||||
const directArchiveUrl =
|
||||
`https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/` +
|
||||
`xcode-${encodeURIComponent(xcodeVersion)}-wda-${encodeURIComponent(wdaVersion)}.tar.gz`;
|
||||
const proxiedArchiveUrl = getProxiedDownloadUrl({
|
||||
directUrl: directArchiveUrl,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
|
||||
logger.info(`Downloading the prebuilt WebDriverAgent cache for Xcode ${xcodeVersion}`);
|
||||
if (proxiedArchiveUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
'Failed to download the prebuilt WebDriverAgent cache via the proxy; falling back to the direct URL.'
|
||||
);
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await downloadFile(directArchiveUrl, archivePath, {
|
||||
retry: 3,
|
||||
timeout: MAESTRO_RUNNER_WDA_CACHE_DOWNLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
await fs.promises.mkdir(maestroRunnerHome, { recursive: true });
|
||||
await spawn('tar', ['-xzf', archivePath, '-C', maestroRunnerHome], { logger, env });
|
||||
|
||||
const genericProductsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
for (const runtimeVersion of iosRuntimeVersions) {
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.cp(genericProductsDirectory, productsDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Installed the prebuilt WebDriverAgent cache for iOS ${iosRuntimeVersions.join(', ')}.`
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to install the prebuilt WebDriverAgent cache. maestro-runner will build WebDriverAgent when it is needed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMaestroRunnerWdaVersion({
|
||||
maestroRunnerHome,
|
||||
}: {
|
||||
maestroRunnerHome: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.promises.readFile(
|
||||
path.join(maestroRunnerHome, 'drivers', 'ios', 'WebDriverAgent', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return xcodeVersion;
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
|
||||
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
|
||||
|
||||
export function createLaunchApplicationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -26,6 +29,16 @@ export function createLaunchApplicationFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ global, logger }, { inputs, env }) => {
|
||||
const applicationIdentifier = parseNonEmptyStringInput(
|
||||
@@ -36,9 +49,14 @@ export function createLaunchApplicationFunction(): BuildFunction {
|
||||
inputs.activity_name.value === undefined
|
||||
? undefined
|
||||
: parseNonEmptyStringInput(inputs.activity_name.value, 'activity_name');
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
@@ -50,22 +68,31 @@ export function createLaunchApplicationFunction(): BuildFunction {
|
||||
export async function launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
applicationIdentifier: string;
|
||||
activityName?: string;
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Launching ${applicationIdentifier}.`);
|
||||
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier], {
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
await spawn('xcrun', ['simctl', 'launch', 'booted', applicationIdentifier, ...launchArgs], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
if (openUrl) {
|
||||
await preapproveIosUrlSchemeAsync({ applicationIdentifier, openUrl, env, logger });
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn('xcrun', ['simctl', 'openurl', 'booted', openUrl], { env, logger });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,11 +103,85 @@ export async function launchApplicationAsync({
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Launching ${applicationIdentifier}.`);
|
||||
await spawn('adb', ['shell', 'am', 'start', '-n', `${applicationIdentifier}/${activityName}`], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
openUrl,
|
||||
'-n',
|
||||
`${applicationIdentifier}/${activityName}`,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
applicationIdentifier: string;
|
||||
openUrl: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const urlScheme = new URL(openUrl).protocol.slice(0, -1);
|
||||
if (urlScheme === 'http' || urlScheme === 'https') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
IOS_URL_SCHEME_APPROVAL_DOMAIN,
|
||||
`${IOS_URL_SCHEME_APPROVAL_KEY_PREFIX}${urlScheme}`,
|
||||
'-string',
|
||||
applicationIdentifier,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not preapprove the ${urlScheme} URL scheme for ${applicationIdentifier}. Opening the URL anyway; the Simulator might require manual confirmation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logApplicationLaunch(
|
||||
logger: bunyan,
|
||||
applicationIdentifier: string,
|
||||
launchArgs: string[]
|
||||
): void {
|
||||
const argumentsDescription =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
@@ -92,3 +193,27 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(argument => typeof argument === 'string')) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "launch_args" must be an array of strings.'
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Input "open_url" must be a valid URL.'
|
||||
);
|
||||
}
|
||||
return openUrl;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { APPIUM_COMMAND_SUMMARIES, humanizeAppiumCommand } from '../appiumCommandSummary';
|
||||
|
||||
// Coverage of the generated Appium and driver command sets is enforced at compile time by the
|
||||
// `Record<AppiumCommand, string>` type in appiumCommandSummary.ts, so it is not
|
||||
// re-checked here.
|
||||
|
||||
describe('APPIUM_COMMAND_SUMMARIES', () => {
|
||||
it('has a non-empty summary that differs from the command name for every entry', () => {
|
||||
for (const [command, summary] of Object.entries(APPIUM_COMMAND_SUMMARIES)) {
|
||||
expect(summary).toBeTruthy();
|
||||
expect(summary).not.toBe(command);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(humanizeAppiumCommand, () => {
|
||||
it.each([
|
||||
['getWindowRect', 'Read the screen size'],
|
||||
['getScreenshot', 'Took a screenshot'],
|
||||
['getPageSource', 'Read the screen contents'],
|
||||
['click', 'Tapped an element'],
|
||||
['setValue', 'Typed into an element'],
|
||||
['createSession', 'Started the session'],
|
||||
['deleteSession', 'Ended the session'],
|
||||
['postAcceptAlert', 'Accepted an alert'],
|
||||
['mobileScroll', 'Scrolled the screen'],
|
||||
['openNotifications', 'Opened notifications'],
|
||||
])('maps the known command %s to a curated summary', (command, expected) => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['proxyReqRes', 'mobile: scroll', 'getDeviceInfo', 'toString', 'constructor', ''])(
|
||||
'returns the raw command %p when it has no curated summary',
|
||||
command => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(command);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -58,7 +58,10 @@ describe(startAppiumEventCollectionAsync, () => {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
summary: 'Read the screen orientation',
|
||||
data: {
|
||||
command: 'getOrientation',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -67,10 +67,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -84,10 +91,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
@@ -143,6 +157,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
@@ -185,7 +200,42 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
it('uses the semantic kind reported by Argent', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'native-profile-report.md',
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile-report.md',
|
||||
kind: 'native-profile-report',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the semantic kind when a directory is uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-trace',
|
||||
filename: 'native-profile.trace',
|
||||
mimeType: 'application/octet-stream',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile.trace.tar.gz',
|
||||
kind: 'native-profile-trace',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an older unclassified directory artifact without a kind', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { type AppiumCommand } from './appiumCommands.generated';
|
||||
|
||||
export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
// Session lifecycle
|
||||
createSession: 'Started the session',
|
||||
deleteSession: 'Ended the session',
|
||||
getSession: 'Read the session details',
|
||||
getStatus: 'Checked the server status',
|
||||
getAppiumSessions: 'Listed Appium sessions',
|
||||
getAppiumSessionCapabilities: 'Read the session capabilities',
|
||||
getTimeouts: 'Read timeouts',
|
||||
timeouts: 'Set timeouts',
|
||||
getSettings: 'Read Appium settings',
|
||||
updateSettings: 'Updated Appium settings',
|
||||
listCommands: 'Listed available commands',
|
||||
listExtensions: 'Listed available extensions',
|
||||
receiveAsyncResponse: 'Received an async response',
|
||||
|
||||
// Screen and media
|
||||
getScreenshot: 'Took a screenshot',
|
||||
getElementScreenshot: 'Took a screenshot of an element',
|
||||
getPageSource: 'Read the screen contents',
|
||||
printPage: 'Printed the page',
|
||||
|
||||
// Window and orientation
|
||||
getWindowRect: 'Read the screen size',
|
||||
setWindowRect: 'Resized the window',
|
||||
maximizeWindow: 'Maximized the window',
|
||||
minimizeWindow: 'Minimized the window',
|
||||
fullScreenWindow: 'Made the window full screen',
|
||||
getWindowHandle: 'Read the current window',
|
||||
getWindowHandles: 'Listed open windows',
|
||||
setWindow: 'Switched window',
|
||||
closeWindow: 'Closed the window',
|
||||
createNewWindow: 'Opened a new window',
|
||||
setFrame: 'Switched to a frame',
|
||||
switchToParentFrame: 'Switched to the parent frame',
|
||||
getOrientation: 'Read the screen orientation',
|
||||
setOrientation: 'Changed the screen orientation',
|
||||
getRotation: 'Read the device rotation',
|
||||
setRotation: 'Rotated the device',
|
||||
|
||||
// Device
|
||||
getDeviceTime: 'Read the device time',
|
||||
getGeoLocation: 'Read the device location',
|
||||
setGeoLocation: 'Set the device location',
|
||||
getNetworkConnection: 'Read the network connection',
|
||||
setNetworkConnection: 'Changed the network connection',
|
||||
setDevicePosture: 'Set the device posture',
|
||||
clearDevicePosture: 'Cleared the device posture',
|
||||
setPermissions: 'Set app permissions',
|
||||
|
||||
// Element discovery
|
||||
findElement: 'Found an element',
|
||||
findElements: 'Found elements',
|
||||
findElementFromElement: 'Found a nested element',
|
||||
findElementsFromElement: 'Found nested elements',
|
||||
findElementFromShadowRoot: 'Found an element in a shadow root',
|
||||
findElementsFromShadowRoot: 'Found elements in a shadow root',
|
||||
elementShadowRoot: 'Read an element shadow root',
|
||||
active: 'Read the focused element',
|
||||
|
||||
// Element interaction
|
||||
click: 'Tapped an element',
|
||||
clear: 'Cleared an element',
|
||||
setValue: 'Typed into an element',
|
||||
getText: 'Read element text',
|
||||
getName: 'Read an element tag',
|
||||
getAttribute: 'Read an element attribute',
|
||||
getProperty: 'Read an element property',
|
||||
getCssProperty: 'Read an element style',
|
||||
getComputedLabel: 'Read an element label',
|
||||
getComputedRole: 'Read an element role',
|
||||
getElementRect: 'Read an element position and size',
|
||||
elementDisplayed: 'Checked if an element was visible',
|
||||
elementEnabled: 'Checked if an element was enabled',
|
||||
elementSelected: 'Checked if an element was selected',
|
||||
|
||||
// Gestures
|
||||
performActions: 'Performed a gesture',
|
||||
releaseActions: 'Finished a gesture',
|
||||
|
||||
// App management
|
||||
installApp: 'Installed an app',
|
||||
removeApp: 'Removed an app',
|
||||
isAppInstalled: 'Checked if an app was installed',
|
||||
activateApp: 'Activated an app',
|
||||
terminateApp: 'Terminated an app',
|
||||
queryAppState: 'Read the app state',
|
||||
background: 'Sent the app to the background',
|
||||
|
||||
// Contexts
|
||||
getContexts: 'Listed available contexts',
|
||||
getCurrentContext: 'Read the current context',
|
||||
setContext: 'Switched context',
|
||||
|
||||
// Web navigation
|
||||
setUrl: 'Opened a URL',
|
||||
getUrl: 'Read the current URL',
|
||||
back: 'Navigated back',
|
||||
forward: 'Navigated forward',
|
||||
refresh: 'Refreshed the page',
|
||||
title: 'Read the page title',
|
||||
|
||||
// Cookies
|
||||
getCookie: 'Read a cookie',
|
||||
getCookies: 'Read all cookies',
|
||||
setCookie: 'Set a cookie',
|
||||
deleteCookie: 'Deleted a cookie',
|
||||
deleteCookies: 'Deleted all cookies',
|
||||
|
||||
// Alerts
|
||||
getAlertText: 'Read an alert',
|
||||
setAlertText: 'Typed into an alert',
|
||||
postAcceptAlert: 'Accepted an alert',
|
||||
postDismissAlert: 'Dismissed an alert',
|
||||
|
||||
// Keyboard and input methods
|
||||
hideKeyboard: 'Hid the keyboard',
|
||||
isKeyboardShown: 'Checked if the keyboard was shown',
|
||||
activateIMEEngine: 'Activated an input method',
|
||||
deactivateIMEEngine: 'Deactivated the input method',
|
||||
getActiveIMEEngine: 'Read the active input method',
|
||||
availableIMEEngines: 'Listed input methods',
|
||||
isIMEActivated: 'Checked if an input method was active',
|
||||
|
||||
// Clipboard (driver-level, not declared by base-driver)
|
||||
getClipboard: 'Read the clipboard',
|
||||
setClipboard: 'Wrote to the clipboard',
|
||||
|
||||
// Files
|
||||
pushFile: 'Pushed a file to the device',
|
||||
pullFile: 'Pulled a file from the device',
|
||||
pullFolder: 'Pulled a folder from the device',
|
||||
|
||||
// Logs
|
||||
getLog: 'Read device logs',
|
||||
getLogTypes: 'Listed log types',
|
||||
getLogEvents: 'Read logged events',
|
||||
logCustomEvent: 'Logged a custom event',
|
||||
generateTestReport: 'Generated a test report',
|
||||
|
||||
// Scripts
|
||||
execute: 'Ran a script command',
|
||||
executeAsync: 'Ran an async script command',
|
||||
executeCdp: 'Ran a Chrome DevTools command',
|
||||
|
||||
// Web authentication (virtual authenticators)
|
||||
addVirtualAuthenticator: 'Added a virtual authenticator',
|
||||
removeVirtualAuthenticator: 'Removed a virtual authenticator',
|
||||
addAuthCredential: 'Added an auth credential',
|
||||
getAuthCredential: 'Read auth credentials',
|
||||
removeAuthCredential: 'Removed an auth credential',
|
||||
removeAllAuthCredentials: 'Removed all auth credentials',
|
||||
setUserAuthVerified: 'Set the user verification state',
|
||||
|
||||
// Federated sign-in (FedCM)
|
||||
fedCMGetAccounts: 'Listed federated sign-in accounts',
|
||||
fedCMSelectAccount: 'Selected a federated sign-in account',
|
||||
fedCMGetDialogType: 'Read the federated sign-in dialog type',
|
||||
fedCMGetTitle: 'Read the federated sign-in dialog title',
|
||||
fedCMClickDialogButton: 'Clicked a federated sign-in dialog button',
|
||||
fedCMCancelDialog: 'Canceled the federated sign-in dialog',
|
||||
fedCMResetCooldown: 'Reset the federated sign-in cooldown',
|
||||
fedCMSetDelayEnabled: 'Toggled the federated sign-in delay',
|
||||
|
||||
// Virtual sensors and pressure sources
|
||||
createVirtualSensor: 'Created a virtual sensor',
|
||||
updateVirtualSensorReading: 'Updated a virtual sensor reading',
|
||||
getVirtualSensorInfo: 'Read virtual sensor info',
|
||||
deleteVirtualSensor: 'Removed a virtual sensor',
|
||||
createVirtualPressureSource: 'Created a virtual pressure source',
|
||||
updateVirtualPressureSource: 'Updated a virtual pressure source',
|
||||
deleteVirtualPressureSource: 'Removed a virtual pressure source',
|
||||
|
||||
// Privacy / experimental web platform
|
||||
getGlobalPrivacyControl: 'Read the Global Privacy Control setting',
|
||||
setGlobalPrivacyControl: 'Set the Global Privacy Control setting',
|
||||
setStorageAccess: 'Set storage access',
|
||||
setRPHRegistrationMode: 'Set the protocol handler registration mode',
|
||||
setSPCTransactionMode: 'Set the payment transaction mode',
|
||||
|
||||
// Driver routes and compatibility commands
|
||||
asyncScriptTimeout: 'Set the async script timeout',
|
||||
closeApp: 'Closed the app',
|
||||
disableConditionInducer: 'Disabled the condition inducer',
|
||||
enableConditionInducer: 'Enabled the condition inducer',
|
||||
fingerprint: 'Simulated a fingerprint',
|
||||
getCurrentActivity: 'Read the current activity',
|
||||
getCurrentPackage: 'Read the current package',
|
||||
getDisplayDensity: 'Read the display density',
|
||||
getLocation: 'Read an element location',
|
||||
getLocationInView: 'Read an element location in view',
|
||||
getPerformanceData: 'Read performance data',
|
||||
getPerformanceDataTypes: 'Listed performance data types',
|
||||
getScreenInfo: 'Read screen information',
|
||||
getSize: 'Read an element size',
|
||||
getStrings: 'Read app strings',
|
||||
getSystemBars: 'Read system bar information',
|
||||
getViewportRect: 'Read the viewport size',
|
||||
getViewportScreenshot: 'Took a viewport screenshot',
|
||||
getWindowSize: 'Read the window size',
|
||||
gsmCall: 'Simulated a phone call',
|
||||
gsmSignal: 'Changed the cellular signal',
|
||||
gsmVoice: 'Changed the cellular voice state',
|
||||
implicitWait: 'Set the element lookup timeout',
|
||||
isLocationServicesEnabled: 'Checked if location services were enabled',
|
||||
isLocked: 'Checked if the device was locked',
|
||||
keyevent: 'Sent a key event',
|
||||
keys: 'Sent keyboard input',
|
||||
launchApp: 'Launched the app',
|
||||
listConditionInducers: 'Listed condition inducers',
|
||||
lock: 'Locked the device',
|
||||
longPressKeyCode: 'Long-pressed a key',
|
||||
networkSpeed: 'Changed the network speed',
|
||||
openNotifications: 'Opened notifications',
|
||||
powerAC: 'Changed the charging state',
|
||||
powerCapacity: 'Changed the battery level',
|
||||
pressKeyCode: 'Pressed a key',
|
||||
replaceValue: 'Replaced an element value',
|
||||
reset: 'Reset the app',
|
||||
sendSMS: 'Simulated a text message',
|
||||
sensorSet: 'Changed a sensor value',
|
||||
setStylusHandwriting: 'Changed stylus handwriting mode',
|
||||
setValueImmediate: 'Typed into an element immediately',
|
||||
startActivity: 'Started an activity',
|
||||
startAudioRecording: 'Started audio recording',
|
||||
startRecordingScreen: 'Started screen recording',
|
||||
stopAudioRecording: 'Stopped audio recording',
|
||||
stopRecordingScreen: 'Stopped screen recording',
|
||||
submit: 'Submitted an element',
|
||||
toggleData: 'Toggled mobile data',
|
||||
toggleEnrollTouchId: 'Changed Touch ID enrollment',
|
||||
toggleFlightMode: 'Toggled airplane mode',
|
||||
toggleLocationServices: 'Toggled location services',
|
||||
toggleWiFi: 'Toggled Wi-Fi',
|
||||
touchId: 'Simulated Touch ID',
|
||||
unlock: 'Unlocked the device',
|
||||
|
||||
// Driver mobile commands
|
||||
mobileAcceptAlert: 'Accepted an alert',
|
||||
mobileActivateApp: 'Activated an app',
|
||||
mobileBackgroundApp: 'Sent the app to the background',
|
||||
mobileBluetooth: 'Changed Bluetooth state',
|
||||
mobileBroadcast: 'Sent an Android broadcast',
|
||||
mobileCalibrateWebToRealCoordinatesTranslation: 'Calibrated web coordinates',
|
||||
mobileChangePermissions: 'Changed app permissions',
|
||||
mobileClearApp: 'Cleared app data',
|
||||
mobileClearKeychains: 'Cleared keychains',
|
||||
mobileClickGesture: 'Performed a click gesture',
|
||||
mobileConfigureLocalization: 'Changed device localization',
|
||||
mobileDeepLink: 'Opened a deep link',
|
||||
mobileDeleteFile: 'Deleted a device file',
|
||||
mobileDeleteFolder: 'Deleted a device folder',
|
||||
mobileDeviceidle: 'Changed device idle state',
|
||||
mobileDisableVoiceOver: 'Disabled VoiceOver',
|
||||
mobileDismissAlert: 'Dismissed an alert',
|
||||
mobileDoubleClickGesture: 'Performed a double-click gesture',
|
||||
mobileDoubleTap: 'Double-tapped the screen',
|
||||
mobileDragFromToForDuration: 'Dragged between screen points',
|
||||
mobileDragFromToWithVelocity: 'Dragged with a set velocity',
|
||||
mobileDragGesture: 'Performed a drag gesture',
|
||||
mobileEnableVoiceOver: 'Enabled VoiceOver',
|
||||
mobileEnrollBiometric: 'Changed biometric enrollment',
|
||||
mobileExecEmuConsoleCommand: 'Ran an emulator console command',
|
||||
mobileExpectNotification: 'Waited for a notification',
|
||||
mobileFingerprint: 'Simulated a fingerprint',
|
||||
mobileFlingGesture: 'Performed a fling gesture',
|
||||
mobileForcePress: 'Force-pressed the screen',
|
||||
mobileGetActionHistory: 'Read scheduled action history',
|
||||
mobileGetActiveAppInfo: 'Read active app information',
|
||||
mobileGetAppearance: 'Read the device appearance',
|
||||
mobileGetBatteryInfo: 'Read battery information',
|
||||
mobileGetChromeCapabilities: 'Read Chrome capabilities',
|
||||
mobileGetConnectivity: 'Read device connectivity',
|
||||
mobileGetContentSize: 'Read scrollable content size',
|
||||
mobileGetContexts: 'Listed available contexts',
|
||||
mobileGetDeviceInfo: 'Read device information',
|
||||
mobileGetDeviceTime: 'Read the device time',
|
||||
mobileGetDeclaredOrientation: 'Read the declared device orientation',
|
||||
mobileGetGeolocation: 'Read the device location',
|
||||
mobileGetIncreaseContrast: 'Read the Increase Contrast setting',
|
||||
mobileGetNotifications: 'Read notifications',
|
||||
mobileGetPasteboard: 'Read the pasteboard',
|
||||
mobileGetPerformanceData: 'Read app performance data',
|
||||
mobileGetPermission: 'Read an app permission',
|
||||
mobileGetPermissions: 'Read app permissions',
|
||||
mobileGetSimulatedLocation: 'Read the simulated location',
|
||||
mobileGetSource: 'Read the screen contents',
|
||||
mobileGetUiMode: 'Read the Android UI mode',
|
||||
mobileGetXctestScreenRecordingInfo: 'Read XCTest screen recording information',
|
||||
mobileGsmCall: 'Simulated a phone call',
|
||||
mobileGsmSignal: 'Changed the cellular signal',
|
||||
mobileGsmVoice: 'Changed the cellular voice state',
|
||||
mobileHandleAlert: 'Handled an alert',
|
||||
mobileHideKeyboard: 'Hid the keyboard',
|
||||
mobileInjectEmulatorCameraImage: 'Injected an emulator camera image',
|
||||
mobileInstallApp: 'Installed an app',
|
||||
mobileInstallCertificate: 'Installed a certificate',
|
||||
mobileInstallMultipleApks: 'Installed multiple APKs',
|
||||
mobileInstallXCTestBundle: 'Installed an XCTest bundle',
|
||||
mobileIsAppInstalled: 'Checked if an app was installed',
|
||||
mobileIsBiometricEnrolled: 'Checked biometric enrollment',
|
||||
mobileIsMediaProjectionRecordingRunning: 'Checked media projection recording',
|
||||
mobileIsVoiceOverEnabled: 'Checked if VoiceOver was enabled',
|
||||
mobileKeys: 'Sent keyboard input',
|
||||
mobileKillApp: 'Stopped an app',
|
||||
mobileLaunchApp: 'Launched an app',
|
||||
mobileListApps: 'Listed installed apps',
|
||||
mobileListCertificates: 'Listed certificates',
|
||||
mobileListDisplays: 'Listed displays',
|
||||
mobileListSms: 'Listed text messages',
|
||||
mobileListWindows: 'Listed app windows',
|
||||
mobileListXCTestBundles: 'Listed XCTest bundles',
|
||||
mobileLongClickGesture: 'Performed a long-click gesture',
|
||||
mobileNetworkSpeed: 'Changed the network speed',
|
||||
mobileNfc: 'Changed NFC state',
|
||||
mobilePerformAccessibilityAudit: 'Ran an accessibility audit',
|
||||
mobilePerformEditorAction: 'Performed a keyboard editor action',
|
||||
mobilePerformHandGesture: 'Performed a hand gesture',
|
||||
mobilePerformIndigoHidEvent: 'Performed an Indigo HID event',
|
||||
mobilePerformIoHidEvent: 'Performed an IOHID event',
|
||||
mobilePerformStatusBarCommand: 'Performed a status bar command',
|
||||
mobilePinch: 'Pinched the screen',
|
||||
mobilePinchCloseGesture: 'Performed a pinch-close gesture',
|
||||
mobilePinchOpenGesture: 'Performed a pinch-open gesture',
|
||||
mobilePowerAc: 'Changed the charging state',
|
||||
mobilePowerCapacity: 'Changed the battery level',
|
||||
mobilePressButton: 'Pressed a device button',
|
||||
mobilePressKey: 'Pressed a key',
|
||||
mobilePullFile: 'Pulled a file from the device',
|
||||
mobilePullFolder: 'Pulled a folder from the device',
|
||||
mobilePushFile: 'Pushed a file to the device',
|
||||
mobilePushNotification: 'Sent a push notification',
|
||||
mobileQueryAppState: 'Read the app state',
|
||||
mobileRefreshGpsCache: 'Refreshed the GPS cache',
|
||||
mobileRemoveApp: 'Removed an app',
|
||||
mobileRemoveCertificate: 'Removed a certificate',
|
||||
mobileReplaceElementValue: 'Replaced an element value',
|
||||
mobileResetAccessibilityCache: 'Reset the accessibility cache',
|
||||
mobileResetGeolocation: 'Reset the device location',
|
||||
mobileResetLocationService: 'Reset location services',
|
||||
mobileResetPermission: 'Reset an app permission',
|
||||
mobileResetSimulatedLocation: 'Reset the simulated location',
|
||||
mobileRotateDigitalCrown: 'Rotated the Digital Crown',
|
||||
mobileRotateElement: 'Rotated an element',
|
||||
mobileRunXCTest: 'Ran an XCTest bundle',
|
||||
mobileScheduleAction: 'Scheduled a device action',
|
||||
mobileScreenshots: 'Took display screenshots',
|
||||
mobileScroll: 'Scrolled the screen',
|
||||
mobileScrollBackTo: 'Scrolled back to an element',
|
||||
mobileScrollGesture: 'Performed a scroll gesture',
|
||||
mobileScrollToElement: 'Scrolled to an element',
|
||||
mobileSelectPickerWheelValue: 'Changed a picker wheel value',
|
||||
mobileSendBiometricMatch: 'Simulated biometric authentication',
|
||||
mobileSendMemoryWarning: 'Sent a memory warning',
|
||||
mobileSendSms: 'Simulated a text message',
|
||||
mobileSendTrimMemory: 'Sent an Android memory trim event',
|
||||
mobileSetAppearance: 'Changed the device appearance',
|
||||
mobileSetConnectivity: 'Changed device connectivity',
|
||||
mobileSetContentSize: 'Changed the content size',
|
||||
mobileSetGeolocation: 'Set the device location',
|
||||
mobileSetIncreaseContrast: 'Changed the Increase Contrast setting',
|
||||
mobileSetPasteboard: 'Wrote to the pasteboard',
|
||||
mobileSetPermissions: 'Set app permissions',
|
||||
mobileSetSimulatedLocation: 'Set a simulated location',
|
||||
mobileSetUiMode: 'Changed the Android UI mode',
|
||||
mobileShake: 'Shook the device',
|
||||
mobileShell: 'Ran an Android shell command',
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
mobileStartPerfRecord: 'Started performance recording',
|
||||
mobileStartScreenRecording: 'Started screen recording',
|
||||
mobileStartScreenStreaming: 'Started screen streaming',
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
mobileStopPerfRecord: 'Stopped performance recording',
|
||||
mobileStopScreenRecording: 'Stopped screen recording',
|
||||
mobileStopScreenStreaming: 'Stopped screen streaming',
|
||||
mobileStopService: 'Stopped a service',
|
||||
mobileStopSystemMonitor: 'Stopped system monitoring',
|
||||
mobileStopXctestScreenRecording: 'Stopped XCTest screen recording',
|
||||
mobileSwipe: 'Swiped the screen',
|
||||
mobileSwipeGesture: 'Performed a swipe gesture',
|
||||
mobileTap: 'Tapped the screen',
|
||||
mobileTapWithNumberOfTaps: 'Tapped the screen multiple times',
|
||||
mobileTerminateApp: 'Terminated an app',
|
||||
mobileTouchAndHold: 'Touched and held the screen',
|
||||
mobileTwoFingerTap: 'Tapped with two fingers',
|
||||
mobileType: 'Typed text',
|
||||
mobileUnlock: 'Unlocked the device',
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a raw Appium command name into a short, human-readable summary.
|
||||
*
|
||||
* If a command has no curated summary, the raw command name is returned
|
||||
* unchanged — we intentionally do not guess a phrasing.
|
||||
*/
|
||||
export function humanizeAppiumCommand(command: string): string {
|
||||
return Object.hasOwn(APPIUM_COMMAND_SUMMARIES, command)
|
||||
? (APPIUM_COMMAND_SUMMARIES as Record<string, string>)[command]
|
||||
: command;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run `yarn workspace @expo/build-tools generate-appium-commands` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
export type BaseDriverAppiumCommand =
|
||||
| 'activateApp'
|
||||
| 'activateIMEEngine'
|
||||
| 'active'
|
||||
| 'addAuthCredential'
|
||||
| 'addVirtualAuthenticator'
|
||||
| 'availableIMEEngines'
|
||||
| 'back'
|
||||
| 'clear'
|
||||
| 'clearDevicePosture'
|
||||
| 'click'
|
||||
| 'closeWindow'
|
||||
| 'createNewWindow'
|
||||
| 'createSession'
|
||||
| 'createVirtualPressureSource'
|
||||
| 'createVirtualSensor'
|
||||
| 'deactivateIMEEngine'
|
||||
| 'deleteCookie'
|
||||
| 'deleteCookies'
|
||||
| 'deleteSession'
|
||||
| 'deleteVirtualPressureSource'
|
||||
| 'deleteVirtualSensor'
|
||||
| 'elementDisplayed'
|
||||
| 'elementEnabled'
|
||||
| 'elementSelected'
|
||||
| 'elementShadowRoot'
|
||||
| 'execute'
|
||||
| 'executeAsync'
|
||||
| 'executeCdp'
|
||||
| 'fedCMCancelDialog'
|
||||
| 'fedCMClickDialogButton'
|
||||
| 'fedCMGetAccounts'
|
||||
| 'fedCMGetDialogType'
|
||||
| 'fedCMGetTitle'
|
||||
| 'fedCMResetCooldown'
|
||||
| 'fedCMSelectAccount'
|
||||
| 'fedCMSetDelayEnabled'
|
||||
| 'findElement'
|
||||
| 'findElementFromElement'
|
||||
| 'findElementFromShadowRoot'
|
||||
| 'findElements'
|
||||
| 'findElementsFromElement'
|
||||
| 'findElementsFromShadowRoot'
|
||||
| 'forward'
|
||||
| 'fullScreenWindow'
|
||||
| 'generateTestReport'
|
||||
| 'getActiveIMEEngine'
|
||||
| 'getAlertText'
|
||||
| 'getAppiumSessionCapabilities'
|
||||
| 'getAppiumSessions'
|
||||
| 'getAttribute'
|
||||
| 'getAuthCredential'
|
||||
| 'getComputedLabel'
|
||||
| 'getComputedRole'
|
||||
| 'getContexts'
|
||||
| 'getCookie'
|
||||
| 'getCookies'
|
||||
| 'getCssProperty'
|
||||
| 'getCurrentContext'
|
||||
| 'getDeviceTime'
|
||||
| 'getElementRect'
|
||||
| 'getElementScreenshot'
|
||||
| 'getGeoLocation'
|
||||
| 'getGlobalPrivacyControl'
|
||||
| 'getLog'
|
||||
| 'getLogEvents'
|
||||
| 'getLogTypes'
|
||||
| 'getName'
|
||||
| 'getNetworkConnection'
|
||||
| 'getOrientation'
|
||||
| 'getPageSource'
|
||||
| 'getProperty'
|
||||
| 'getRotation'
|
||||
| 'getScreenshot'
|
||||
| 'getSession'
|
||||
| 'getSettings'
|
||||
| 'getStatus'
|
||||
| 'getText'
|
||||
| 'getTimeouts'
|
||||
| 'getUrl'
|
||||
| 'getVirtualSensorInfo'
|
||||
| 'getWindowHandle'
|
||||
| 'getWindowHandles'
|
||||
| 'getWindowRect'
|
||||
| 'hideKeyboard'
|
||||
| 'installApp'
|
||||
| 'isAppInstalled'
|
||||
| 'isIMEActivated'
|
||||
| 'isKeyboardShown'
|
||||
| 'listCommands'
|
||||
| 'listExtensions'
|
||||
| 'logCustomEvent'
|
||||
| 'maximizeWindow'
|
||||
| 'minimizeWindow'
|
||||
| 'performActions'
|
||||
| 'postAcceptAlert'
|
||||
| 'postDismissAlert'
|
||||
| 'printPage'
|
||||
| 'pullFile'
|
||||
| 'pullFolder'
|
||||
| 'pushFile'
|
||||
| 'queryAppState'
|
||||
| 'receiveAsyncResponse'
|
||||
| 'refresh'
|
||||
| 'releaseActions'
|
||||
| 'removeAllAuthCredentials'
|
||||
| 'removeApp'
|
||||
| 'removeAuthCredential'
|
||||
| 'removeVirtualAuthenticator'
|
||||
| 'setAlertText'
|
||||
| 'setContext'
|
||||
| 'setCookie'
|
||||
| 'setDevicePosture'
|
||||
| 'setFrame'
|
||||
| 'setGeoLocation'
|
||||
| 'setGlobalPrivacyControl'
|
||||
| 'setNetworkConnection'
|
||||
| 'setOrientation'
|
||||
| 'setPermissions'
|
||||
| 'setRPHRegistrationMode'
|
||||
| 'setRotation'
|
||||
| 'setSPCTransactionMode'
|
||||
| 'setStorageAccess'
|
||||
| 'setUrl'
|
||||
| 'setUserAuthVerified'
|
||||
| 'setValue'
|
||||
| 'setWindow'
|
||||
| 'setWindowRect'
|
||||
| 'switchToParentFrame'
|
||||
| 'terminateApp'
|
||||
| 'timeouts'
|
||||
| 'title'
|
||||
| 'updateSettings'
|
||||
| 'updateVirtualPressureSource'
|
||||
| 'updateVirtualSensorReading';
|
||||
|
||||
export type XCUITestAppiumCommand =
|
||||
| 'asyncScriptTimeout'
|
||||
| 'background'
|
||||
| 'closeApp'
|
||||
| 'disableConditionInducer'
|
||||
| 'enableConditionInducer'
|
||||
| 'getClipboard'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getScreenInfo'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getViewportRect'
|
||||
| 'getViewportScreenshot'
|
||||
| 'getWindowSize'
|
||||
| 'implicitWait'
|
||||
| 'isLocked'
|
||||
| 'keys'
|
||||
| 'launchApp'
|
||||
| 'listConditionInducers'
|
||||
| 'lock'
|
||||
| 'mobileActivateApp'
|
||||
| 'mobileCalibrateWebToRealCoordinatesTranslation'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClearKeychains'
|
||||
| 'mobileConfigureLocalization'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeleteFolder'
|
||||
| 'mobileDisableVoiceOver'
|
||||
| 'mobileDoubleTap'
|
||||
| 'mobileDragFromToForDuration'
|
||||
| 'mobileDragFromToWithVelocity'
|
||||
| 'mobileEnableVoiceOver'
|
||||
| 'mobileEnrollBiometric'
|
||||
| 'mobileExpectNotification'
|
||||
| 'mobileForcePress'
|
||||
| 'mobileGetActiveAppInfo'
|
||||
| 'mobileGetAppearance'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetContentSize'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetIncreaseContrast'
|
||||
| 'mobileGetPasteboard'
|
||||
| 'mobileGetPermission'
|
||||
| 'mobileGetSimulatedLocation'
|
||||
| 'mobileGetSource'
|
||||
| 'mobileGetXctestScreenRecordingInfo'
|
||||
| 'mobileHandleAlert'
|
||||
| 'mobileHideKeyboard'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallCertificate'
|
||||
| 'mobileInstallXCTestBundle'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsBiometricEnrolled'
|
||||
| 'mobileIsVoiceOverEnabled'
|
||||
| 'mobileKeys'
|
||||
| 'mobileKillApp'
|
||||
| 'mobileLaunchApp'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListCertificates'
|
||||
| 'mobileListXCTestBundles'
|
||||
| 'mobilePerformAccessibilityAudit'
|
||||
| 'mobilePerformHandGesture'
|
||||
| 'mobilePerformIndigoHidEvent'
|
||||
| 'mobilePerformIoHidEvent'
|
||||
| 'mobilePinch'
|
||||
| 'mobilePressButton'
|
||||
| 'mobilePullFile'
|
||||
| 'mobilePullFolder'
|
||||
| 'mobilePushFile'
|
||||
| 'mobilePushNotification'
|
||||
| 'mobileQueryAppState'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileRemoveCertificate'
|
||||
| 'mobileResetLocationService'
|
||||
| 'mobileResetPermission'
|
||||
| 'mobileResetSimulatedLocation'
|
||||
| 'mobileRotateDigitalCrown'
|
||||
| 'mobileRotateElement'
|
||||
| 'mobileRunXCTest'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollToElement'
|
||||
| 'mobileSelectPickerWheelValue'
|
||||
| 'mobileSendBiometricMatch'
|
||||
| 'mobileSendMemoryWarning'
|
||||
| 'mobileSetAppearance'
|
||||
| 'mobileSetContentSize'
|
||||
| 'mobileSetIncreaseContrast'
|
||||
| 'mobileSetPasteboard'
|
||||
| 'mobileSetPermissions'
|
||||
| 'mobileSetSimulatedLocation'
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
| 'mobileStopScreenRecording'
|
||||
| 'mobileStopSystemMonitor'
|
||||
| 'mobileStopXctestScreenRecording'
|
||||
| 'mobileSwipe'
|
||||
| 'mobileTap'
|
||||
| 'mobileTapWithNumberOfTaps'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileTouchAndHold'
|
||||
| 'mobileTwoFingerTap'
|
||||
| 'mobileUpdateSafariPreferences'
|
||||
| 'mobileVoiceOverCurrentSpeech'
|
||||
| 'mobileVoiceOverMove'
|
||||
| 'reset'
|
||||
| 'setClipboard'
|
||||
| 'setValueImmediate'
|
||||
| 'startAudioRecording'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopAudioRecording'
|
||||
| 'stopRecordingScreen'
|
||||
| 'submit'
|
||||
| 'toggleEnrollTouchId'
|
||||
| 'touchId'
|
||||
| 'unlock';
|
||||
|
||||
export type UiAutomator2AppiumCommand =
|
||||
| 'background'
|
||||
| 'fingerprint'
|
||||
| 'getClipboard'
|
||||
| 'getCurrentActivity'
|
||||
| 'getCurrentPackage'
|
||||
| 'getDisplayDensity'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getPerformanceData'
|
||||
| 'getPerformanceDataTypes'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getSystemBars'
|
||||
| 'getWindowSize'
|
||||
| 'gsmCall'
|
||||
| 'gsmSignal'
|
||||
| 'gsmVoice'
|
||||
| 'implicitWait'
|
||||
| 'isLocationServicesEnabled'
|
||||
| 'isLocked'
|
||||
| 'keyevent'
|
||||
| 'keys'
|
||||
| 'lock'
|
||||
| 'longPressKeyCode'
|
||||
| 'mobileAcceptAlert'
|
||||
| 'mobileBackgroundApp'
|
||||
| 'mobileBluetooth'
|
||||
| 'mobileBroadcast'
|
||||
| 'mobileChangePermissions'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClickGesture'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeviceidle'
|
||||
| 'mobileDismissAlert'
|
||||
| 'mobileDoubleClickGesture'
|
||||
| 'mobileDragGesture'
|
||||
| 'mobileExecEmuConsoleCommand'
|
||||
| 'mobileFingerprint'
|
||||
| 'mobileFlingGesture'
|
||||
| 'mobileGetActionHistory'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetChromeCapabilities'
|
||||
| 'mobileGetConnectivity'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeclaredOrientation'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetGeolocation'
|
||||
| 'mobileGetNotifications'
|
||||
| 'mobileGetPerformanceData'
|
||||
| 'mobileGetPermissions'
|
||||
| 'mobileGetUiMode'
|
||||
| 'mobileGsmCall'
|
||||
| 'mobileGsmSignal'
|
||||
| 'mobileGsmVoice'
|
||||
| 'mobileInjectEmulatorCameraImage'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallMultipleApks'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsMediaProjectionRecordingRunning'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListDisplays'
|
||||
| 'mobileListSms'
|
||||
| 'mobileListWindows'
|
||||
| 'mobileLongClickGesture'
|
||||
| 'mobileNetworkSpeed'
|
||||
| 'mobileNfc'
|
||||
| 'mobilePerformEditorAction'
|
||||
| 'mobilePerformStatusBarCommand'
|
||||
| 'mobilePinchCloseGesture'
|
||||
| 'mobilePinchOpenGesture'
|
||||
| 'mobilePowerAc'
|
||||
| 'mobilePowerCapacity'
|
||||
| 'mobilePressKey'
|
||||
| 'mobileRefreshGpsCache'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileReplaceElementValue'
|
||||
| 'mobileResetAccessibilityCache'
|
||||
| 'mobileResetGeolocation'
|
||||
| 'mobileScheduleAction'
|
||||
| 'mobileScreenshots'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollBackTo'
|
||||
| 'mobileScrollGesture'
|
||||
| 'mobileSendSms'
|
||||
| 'mobileSendTrimMemory'
|
||||
| 'mobileSetConnectivity'
|
||||
| 'mobileSetGeolocation'
|
||||
| 'mobileSetUiMode'
|
||||
| 'mobileShell'
|
||||
| 'mobileStartActivity'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartMediaProjectionRecording'
|
||||
| 'mobileStartScreenStreaming'
|
||||
| 'mobileStartService'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopMediaProjectionRecording'
|
||||
| 'mobileStopScreenStreaming'
|
||||
| 'mobileStopService'
|
||||
| 'mobileSwipeGesture'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileType'
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
| 'powerAC'
|
||||
| 'powerCapacity'
|
||||
| 'pressKeyCode'
|
||||
| 'replaceValue'
|
||||
| 'sendSMS'
|
||||
| 'sensorSet'
|
||||
| 'setClipboard'
|
||||
| 'setStylusHandwriting'
|
||||
| 'setValueImmediate'
|
||||
| 'startActivity'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopRecordingScreen'
|
||||
| 'toggleData'
|
||||
| 'toggleFlightMode'
|
||||
| 'toggleLocationServices'
|
||||
| 'toggleWiFi'
|
||||
| 'unlock';
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { humanizeAppiumCommand } from './appiumCommandSummary';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
@@ -146,7 +147,7 @@ function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
summary: humanizeAppiumCommand(command.cmd),
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
|
||||
@@ -18,11 +18,11 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
||||
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
||||
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
||||
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
||||
// set, so a `screen-recording` kind here does not add a player to the session page.
|
||||
// Fallback kinds for Argent versions that do not include a semantic artifact kind. Only media types
|
||||
// the EAS dashboard renders specially can be inferred safely. Note that these kinds only affect
|
||||
// grouping and labelling. The dashboard gates its inline video player on the
|
||||
// `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not set, so a
|
||||
// `screen-recording` kind here does not add a player to the session page.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
@@ -32,6 +32,9 @@ const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
// Optional for compatibility with older Argent tool servers. Keep this open to new kinds so a
|
||||
// newer Argent version can add a category without requiring an EAS worker release first.
|
||||
kind: z.string().optional(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
isDirectory: z.boolean().optional(),
|
||||
@@ -43,8 +46,12 @@ const ArgentArtifactsListResponseSchema = z.object({
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
|
||||
if (artifact.kind) {
|
||||
return artifact.kind;
|
||||
}
|
||||
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
||||
// contents rather than the file we actually store.
|
||||
// contents rather than the file we actually store. A semantic kind above remains valid because it
|
||||
// describes what the artifact represents, not its transport format.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getProxiedDownloadUrl } from '../../../utils/download';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
@@ -294,24 +295,6 @@ function getXclogparserZipName(version: string): string {
|
||||
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
|
||||
}
|
||||
|
||||
function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
|
||||
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
|
||||
const detailStepType = step.detailStepType ?? '';
|
||||
const signature = step.signature ?? '';
|
||||
|
||||
@@ -44,6 +44,32 @@ export namespace IosSimulatorUtils {
|
||||
};
|
||||
};
|
||||
|
||||
export type IosSimulatorRuntime = {
|
||||
identifier: string;
|
||||
isAvailable?: boolean;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type XcrunSimctlListRuntimesJsonOutput = {
|
||||
runtimes: (Omit<IosSimulatorRuntime, 'identifier'> & { identifier?: string })[];
|
||||
};
|
||||
|
||||
export async function getAvailableRuntimesAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorRuntime[]> {
|
||||
const { stdout } = await spawn('xcrun', ['simctl', 'list', 'runtimes', '--json'], { env });
|
||||
const { runtimes } = JSON.parse(stdout) as XcrunSimctlListRuntimesJsonOutput;
|
||||
|
||||
return runtimes.flatMap(runtime =>
|
||||
runtime.isAvailable !== false &&
|
||||
runtime.identifier?.startsWith('com.apple.CoreSimulator.SimRuntime.iOS-')
|
||||
? [{ ...runtime, identifier: runtime.identifier }]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAvailableDevicesAsync({
|
||||
env,
|
||||
filter,
|
||||
|
||||
@@ -23,6 +23,41 @@ describe('IosSimulatorUtils', () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
it('returns available iOS runtimes', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.getAvailableRuntimesAsync({ env: process.env })
|
||||
).resolves.toEqual([
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
]);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'list', 'runtimes', '--json'], {
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
|
||||
it('adds a human-readable runtime display name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getProxiedDownloadUrl } from '../download';
|
||||
|
||||
describe(getProxiedDownloadUrl, () => {
|
||||
it('returns null when no proxy base URL is set', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({ directUrl: 'https://storage.googleapis.com/bucket/object' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the origin to the proxy base URL', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({
|
||||
directUrl: 'https://storage.googleapis.com/bucket/object',
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
})
|
||||
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
|
||||
});
|
||||
|
||||
it('normalizes a proxy base URL with a trailing slash', () => {
|
||||
expect(
|
||||
getProxiedDownloadUrl({
|
||||
directUrl: 'https://storage.googleapis.com/bucket/object',
|
||||
proxyBaseUrl: 'https://cache.example.com/',
|
||||
})
|
||||
).toBe('https://cache.example.com/storage.googleapis.com/bucket/object');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.2.0",
|
||||
"version": "22.4.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.1.0"
|
||||
"@expo/steps": "22.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
+205
-130
File diff suppressed because it is too large.
Load diff
Generated
+58
-2
@@ -37213,7 +37213,7 @@
|
||||
},
|
||||
{
|
||||
"name": "applicationArchiveUrl",
|
||||
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId.",
|
||||
"description": "Application archive URL to download, install, and launch before the simulator session\nbecomes available. Mutually exclusive with buildId and expoGo.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -37225,7 +37225,7 @@
|
||||
},
|
||||
{
|
||||
"name": "buildId",
|
||||
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl.",
|
||||
"description": "EAS Build to install and launch before the simulator session becomes available.\nMutually exclusive with applicationArchiveUrl and expoGo.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
@@ -37247,6 +37247,38 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "expoGo",
|
||||
"description": "Install and launch Expo Go before the simulator session becomes available. The server resolves\nthe platform-specific application archive. Mutually exclusive with buildId and\napplicationArchiveUrl.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "launchArgs",
|
||||
"description": "Arguments passed to the installed application when it is launched. Requires buildId,\napplicationArchiveUrl, or expoGo.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "maxIdleTimeMinutes",
|
||||
"description": "Stop the session automatically after this many minutes without observed\nsession activity. Must be positive and smaller than the session's maximum\nduration (2 hours, or maxRunTimeMinutes when set). Only supported for\nagent-device, argent, and Appium sessions. If omitted, the session has no idle\ntimeout.",
|
||||
@@ -37283,6 +37315,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "openUrl",
|
||||
"description": "Expo or development-client URL to open after launching the installed application. Requires\nbuildId, applicationArchiveUrl, or expoGo.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "packageVersion",
|
||||
"description": "The version of the package backing the device run session (e.g. \"0.1.3-alpha.3\").\nIf omitted, consumers treat the session as pinned to \"latest\".",
|
||||
@@ -37311,6 +37355,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sdkVersion",
|
||||
"description": "Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is\ntrue. If omitted, the current Expo Go archive for the platform is used.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"description": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.3.0",
|
||||
"version": "22.5.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -110,6 +110,7 @@
|
||||
"node-forge": "1.4.0",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"open": "8.4.0",
|
||||
"ora": "5.1.0",
|
||||
"pkg-dir": "4.2.0",
|
||||
"pngjs": "7.0.0",
|
||||
@@ -189,6 +190,9 @@
|
||||
"account": {
|
||||
"description": "manage account"
|
||||
},
|
||||
"billing": {
|
||||
"description": "manage account billing and subscriptions"
|
||||
},
|
||||
"branch": {
|
||||
"description": "manage update branches"
|
||||
},
|
||||
|
||||
@@ -15,12 +15,21 @@ const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> =
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = CommandEvent | BuildEvent | SubmissionEvent | MetadataEvent;
|
||||
export type AnalyticsEvent =
|
||||
| CommandEvent
|
||||
| BuildEvent
|
||||
| SubmissionEvent
|
||||
| MetadataEvent
|
||||
| BillingEvent;
|
||||
|
||||
export enum CommandEvent {
|
||||
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
|
||||
}
|
||||
|
||||
export enum BillingEvent {
|
||||
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
|
||||
}
|
||||
|
||||
export enum SubmissionEvent {
|
||||
SUBMIT_COMMAND = 'submit cli submit command',
|
||||
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiV2Client } from '../../api';
|
||||
import { BillingClient } from '../billingClient';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
describe(BillingClient, () => {
|
||||
const postAsync = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(ApiV2Client).mockImplementation(() => ({ postAsync }) as unknown as ApiV2Client);
|
||||
});
|
||||
|
||||
it('unwraps the apiv2 `data` envelope for a checkout session', async () => {
|
||||
postAsync.mockResolvedValue({
|
||||
data: {
|
||||
id: 'cs_123',
|
||||
clientSecret: null,
|
||||
url: 'https://checkout.stripe.com/pay',
|
||||
},
|
||||
});
|
||||
|
||||
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
|
||||
const session = await client.createCheckoutSessionAsync('account-id', 'STARTER');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'cs_123',
|
||||
clientSecret: null,
|
||||
url: 'https://checkout.stripe.com/pay',
|
||||
});
|
||||
expect(postAsync).toHaveBeenCalledWith('stripe-auth/checkout', {
|
||||
body: { accountId: 'account-id', planType: 'STARTER' },
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps the apiv2 `data` envelope for a customer portal session', async () => {
|
||||
postAsync.mockResolvedValue({
|
||||
data: { customerPortal: { url: 'https://billing.stripe.com' } },
|
||||
});
|
||||
|
||||
const client = new BillingClient({ accessToken: 'token', sessionSecret: null });
|
||||
const session = await client.createCustomerPortalSessionAsync('account-id');
|
||||
|
||||
expect(session).toEqual({ url: 'https://billing.stripe.com' });
|
||||
expect(postAsync).toHaveBeenCalledWith('stripe-auth/customer-portal', {
|
||||
body: { accountId: 'account-id' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Permission, Role } from '../../graphql/generated';
|
||||
import { AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import { Actor } from '../../user/User';
|
||||
import { resolveBillingAccountAsync } from '../resolveAccount';
|
||||
|
||||
jest.mock('../../graphql/queries/AccountQuery');
|
||||
|
||||
describe(resolveBillingAccountAsync, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function account(name: string, role: Role): Actor['accounts'][number] {
|
||||
return {
|
||||
id: `${name}-id`,
|
||||
name,
|
||||
viewerUserPermission: { role },
|
||||
} as Actor['accounts'][number];
|
||||
}
|
||||
|
||||
function actor(accounts: Actor['accounts']): Actor {
|
||||
return { id: 'actor-id', accounts } as Actor;
|
||||
}
|
||||
|
||||
async function resolveAsync(
|
||||
accounts: Actor['accounts'],
|
||||
accountName?: string
|
||||
): ReturnType<typeof resolveBillingAccountAsync> {
|
||||
return await resolveBillingAccountAsync({
|
||||
graphqlClient,
|
||||
actor: actor(accounts),
|
||||
accountName,
|
||||
nonInteractive: true,
|
||||
subscriptionFilter: 'unsubscribed',
|
||||
});
|
||||
}
|
||||
|
||||
it('automatically selects the only account with billing permission', async () => {
|
||||
await expect(
|
||||
resolveAsync([account('viewer', Role.ViewOnly), account('admin', Role.Admin)])
|
||||
).resolves.toEqual({ id: 'admin-id', name: 'admin', subscription: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['account owners', Role.Owner],
|
||||
['custom roles with admin permission', Role.HasAdmin],
|
||||
])('allows %s to manage billing', async (_case, role) => {
|
||||
await expect(resolveAsync([account('team', role)])).resolves.toEqual({
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
subscription: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows the owner of a personal account to manage billing', async () => {
|
||||
const ownerAccount = {
|
||||
...account('personal', Role.ViewOnly),
|
||||
ownerUserActor: { __typename: 'User', id: 'actor-id', username: 'personal' },
|
||||
} as Actor['accounts'][number];
|
||||
|
||||
await expect(resolveAsync([ownerAccount])).resolves.toEqual({
|
||||
id: 'personal-id',
|
||||
name: 'personal',
|
||||
subscription: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an explicitly selected account without billing permission', async () => {
|
||||
await expect(resolveAsync([account('developer', Role.Developer)], 'developer')).rejects.toThrow(
|
||||
'You must be an Owner or Admin of account "developer" to manage billing.'
|
||||
);
|
||||
expect(AccountQuery.getByNameAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an account lookup without billing permission', async () => {
|
||||
jest.mocked(AccountQuery.getByNameAsync).mockResolvedValue({
|
||||
id: 'viewer-id',
|
||||
name: 'viewer',
|
||||
viewerUserPermission: { id: 'viewer-permission-id', permissions: [Permission.View] },
|
||||
});
|
||||
|
||||
await expect(resolveAsync([], 'viewer')).rejects.toThrow(
|
||||
'You must be an Owner or Admin of account "viewer" to manage billing.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when no account has billing permission', async () => {
|
||||
await expect(resolveAsync([account('viewer', Role.ViewOnly)])).rejects.toThrow(
|
||||
'You must be an Owner or Admin of at least one account to manage billing.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiV2Client } from '../api';
|
||||
import type { CheckoutPlanType } from './plans';
|
||||
|
||||
export type CheckoutSession = {
|
||||
id: string;
|
||||
clientSecret: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export type CustomerPortalSession = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export class BillingClient {
|
||||
private readonly apiV2Client: ApiV2Client;
|
||||
|
||||
constructor(authInfo: { accessToken: string | null; sessionSecret: string | null }) {
|
||||
this.apiV2Client = new ApiV2Client(authInfo);
|
||||
}
|
||||
|
||||
async createCheckoutSessionAsync(
|
||||
accountId: string,
|
||||
planType: CheckoutPlanType
|
||||
): Promise<CheckoutSession> {
|
||||
const { data } = await this.apiV2Client.postAsync('stripe-auth/checkout', {
|
||||
body: { accountId, planType },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async createCustomerPortalSessionAsync(accountId: string): Promise<CustomerPortalSession> {
|
||||
const { data } = await this.apiV2Client.postAsync('stripe-auth/customer-portal', {
|
||||
body: { accountId },
|
||||
});
|
||||
return data.customerPortal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
|
||||
import openBrowser = require('open');
|
||||
|
||||
export async function openOrPrintUrlAsync(
|
||||
url: string,
|
||||
{ label, open }: { label: string; open: boolean }
|
||||
): Promise<void> {
|
||||
if (!open) {
|
||||
Log.log(`${label}: ${link(url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = ora(`Opening ${label.toLowerCase()}`).start();
|
||||
let opened = false;
|
||||
try {
|
||||
await openBrowser(url);
|
||||
opened = true;
|
||||
} catch (error) {
|
||||
Log.debug(`Failed to open a web browser: ${error}`);
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${label.toLowerCase()} in your browser`);
|
||||
} else {
|
||||
spinner.fail('Unable to open a web browser automatically.');
|
||||
}
|
||||
Log.log(`${label}: ${link(url)}`);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const SUBSCRIBABLE_PLANS = {
|
||||
starter: { planType: 'STARTER', label: 'Starter' },
|
||||
production: { planType: 'PRODUCTION', label: 'Production' },
|
||||
} as const;
|
||||
|
||||
export type PlanSlug = keyof typeof SUBSCRIBABLE_PLANS;
|
||||
export type CheckoutPlanType = (typeof SUBSCRIBABLE_PLANS)[PlanSlug]['planType'];
|
||||
|
||||
export const PLAN_SLUGS = Object.keys(SUBSCRIBABLE_PLANS) as PlanSlug[];
|
||||
|
||||
export const FREE_PLAN_PRICE_ID = 'price_free';
|
||||
|
||||
export function formatStarterSubscribeCommand(accountName?: string): string {
|
||||
return `eas billing:subscribe starter${accountName ? ` --account ${accountName}` : ''}`;
|
||||
}
|
||||
|
||||
export function hasPaidSubscription(
|
||||
subscription: {
|
||||
planId?: string | null;
|
||||
} | null
|
||||
): boolean {
|
||||
return subscription?.planId != null && subscription.planId !== FREE_PLAN_PRICE_ID;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Permission, Role } from '../graphql/generated';
|
||||
import { AccountQuery, type AccountSubscriptionInfo } from '../graphql/queries/AccountQuery';
|
||||
import { hasPaidSubscription } from './plans';
|
||||
import { Actor } from '../user/User';
|
||||
import { ExpoChoice, selectAsync } from '../prompts';
|
||||
|
||||
export type BillingAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
subscription: AccountSubscriptionInfo | null;
|
||||
};
|
||||
|
||||
type SubscriptionFilter = 'subscribed' | 'unsubscribed';
|
||||
|
||||
function billingPermissionError(accountName: string): Error {
|
||||
return new Error(`You must be an Owner or Admin of account "${accountName}" to manage billing.`);
|
||||
}
|
||||
|
||||
function hasBillingRole(actor: Actor, account: Actor['accounts'][number]): boolean {
|
||||
if (account.ownerUserActor?.id === actor.id) {
|
||||
return true;
|
||||
}
|
||||
const { role } = account.viewerUserPermission;
|
||||
return role === Role.Admin || role === Role.Owner || role === Role.HasAdmin;
|
||||
}
|
||||
|
||||
async function getAccountWithSubscriptionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string }
|
||||
): Promise<BillingAccount> {
|
||||
const subscription = await AccountQuery.getSubscriptionAsync(graphqlClient, account.id);
|
||||
return { id: account.id, name: account.name, subscription };
|
||||
}
|
||||
|
||||
function toAccountChoice(account: BillingAccount, disabled: boolean): ExpoChoice<BillingAccount> {
|
||||
const fallback = hasPaidSubscription(account.subscription) ? 'Paid' : 'Free';
|
||||
return {
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: `Current plan: ${account.subscription?.name ?? fallback}`,
|
||||
disabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveNamedAccountAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
accountName: string
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const ownAccount = actor.accounts.find(account => account.name === accountName);
|
||||
if (ownAccount) {
|
||||
if (!hasBillingRole(actor, ownAccount)) {
|
||||
throw billingPermissionError(ownAccount.name);
|
||||
}
|
||||
return ownAccount;
|
||||
}
|
||||
|
||||
const account = await AccountQuery.getByNameAsync(graphqlClient, accountName).catch(() => null);
|
||||
if (!account) {
|
||||
const availableAccounts = actor.accounts
|
||||
.filter(candidate => hasBillingRole(actor, candidate))
|
||||
.map(candidate => candidate.name)
|
||||
.join(', ');
|
||||
throw new Error(
|
||||
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
|
||||
const { permissions } = account.viewerUserPermission;
|
||||
if (!permissions.includes(Permission.Admin) && !permissions.includes(Permission.Own)) {
|
||||
throw billingPermissionError(account.name);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function resolveBillingAccountAsync({
|
||||
graphqlClient,
|
||||
actor,
|
||||
accountName,
|
||||
nonInteractive,
|
||||
subscriptionFilter,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
actor: Actor;
|
||||
accountName: string | undefined;
|
||||
nonInteractive: boolean;
|
||||
subscriptionFilter: SubscriptionFilter;
|
||||
}): Promise<BillingAccount> {
|
||||
if (accountName) {
|
||||
const namedAccount = await resolveNamedAccountAsync(graphqlClient, actor, accountName);
|
||||
const account = await getAccountWithSubscriptionAsync(graphqlClient, namedAccount);
|
||||
if (subscriptionFilter === 'subscribed' && !hasPaidSubscription(account.subscription)) {
|
||||
throw new Error(
|
||||
`Account "${account.name}" does not have an active paid plan. Run eas billing:subscribe to subscribe.`
|
||||
);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
const billingAccounts = actor.accounts.filter(account => hasBillingRole(actor, account));
|
||||
if (billingAccounts.length === 0) {
|
||||
throw new Error('You must be an Owner or Admin of at least one account to manage billing.');
|
||||
}
|
||||
|
||||
const accounts = await Promise.all(
|
||||
billingAccounts.map(account => getAccountWithSubscriptionAsync(graphqlClient, account))
|
||||
);
|
||||
if (subscriptionFilter === 'unsubscribed' && accounts.length === 1) {
|
||||
return accounts[0];
|
||||
}
|
||||
|
||||
const subscribedAccounts = accounts.filter(account => hasPaidSubscription(account.subscription));
|
||||
const unsubscribedAccounts = accounts.filter(
|
||||
account => !hasPaidSubscription(account.subscription)
|
||||
);
|
||||
const eligibleAccounts =
|
||||
subscriptionFilter === 'subscribed' ? subscribedAccounts : unsubscribedAccounts;
|
||||
|
||||
if (eligibleAccounts.length === 0) {
|
||||
throw new Error(
|
||||
subscriptionFilter === 'subscribed'
|
||||
? 'No available accounts have an active paid plan. Run eas billing:subscribe to subscribe an account.'
|
||||
: 'All available accounts already have a paid plan. Run eas billing:manage to change an existing subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
if (eligibleAccounts.length === 1) {
|
||||
return eligibleAccounts[0];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'The --account flag must be provided when running in `--non-interactive` mode and you can manage billing for more than one eligible account.'
|
||||
);
|
||||
}
|
||||
|
||||
const disabledAccounts = subscriptionFilter === 'unsubscribed' ? subscribedAccounts : [];
|
||||
return await selectAsync(
|
||||
'Select an account:',
|
||||
[
|
||||
...eligibleAccounts.map(account => toAccountChoice(account, false)),
|
||||
...disabledAccounts.map(account => toAccountChoice(account, true)),
|
||||
],
|
||||
{
|
||||
initial: eligibleAccounts[0],
|
||||
warningMessageForDisabledEntries:
|
||||
'This account already has a paid plan. Run eas billing:manage to change it.',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -295,39 +295,41 @@ describe(Build.name, () => {
|
||||
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
handleBuildRequestError(error, platform, accountName);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1'
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildFreeTierIosLimitExceededError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
handleBuildRequestError(error, platform, accountName);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
'Error 1'
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { withAnalyticsAsync } from '../analytics/common';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { formatStarterSubscribeCommand } from '../billing/plans';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { createFingerprintAsync } from '../fingerprint/cli';
|
||||
@@ -209,7 +210,7 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
});
|
||||
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
|
||||
} catch (error: any) {
|
||||
handleBuildRequestError(error, job.platform);
|
||||
handleBuildRequestError(error, job.platform, ctx.accountName);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown localBuildMode.');
|
||||
@@ -230,7 +231,11 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
|
||||
VALIDATION_ERROR: RequestValidationError,
|
||||
};
|
||||
|
||||
export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
export function handleBuildRequestError(
|
||||
error: any,
|
||||
platform: Platform,
|
||||
accountName?: string
|
||||
): never {
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
|
||||
const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors)
|
||||
@@ -241,7 +246,15 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
| undefined;
|
||||
if (graphQLErrorCode && graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
|
||||
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
|
||||
throw new ErrorClass(graphQLErrors[0]?.message);
|
||||
const message = graphQLErrors[0]?.message;
|
||||
const isFreeTierLimitError =
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
|
||||
throw new ErrorClass(
|
||||
isFreeTierLimitError
|
||||
? `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`
|
||||
: message
|
||||
);
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
|
||||
throw new EasBuildDownForMaintenanceError(
|
||||
`EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import open from 'open';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { BillingClient } from '../../../billing/billingClient';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Role } from '../../../graphql/generated';
|
||||
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
|
||||
import { ora } from '../../../ora';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../../utils/json';
|
||||
import BillingManage from '../manage';
|
||||
|
||||
jest.mock('open');
|
||||
jest.mock('../../../billing/billingClient');
|
||||
jest.mock('../../../graphql/queries/AccountQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../ora');
|
||||
|
||||
function billingAccount(
|
||||
id: string,
|
||||
name: string
|
||||
): { id: string; name: string; viewerUserPermission: { role: Role } } {
|
||||
return { id, name, viewerUserPermission: { role: Role.Admin } };
|
||||
}
|
||||
|
||||
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
|
||||
|
||||
describe(BillingManage, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const account = billingAccount('account-id', 'testaccount');
|
||||
|
||||
const createCustomerPortalSessionAsync = jest.fn();
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
accounts: ReturnType<typeof billingAccount>[] = [account]
|
||||
): BillingManage {
|
||||
const command = new BillingManage(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
actor: { id: 'actor-id', accounts },
|
||||
authenticationInfo: { accessToken: 'token', sessionSecret: null },
|
||||
},
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
jest
|
||||
.mocked(BillingClient)
|
||||
.mockImplementation(() => ({ createCustomerPortalSessionAsync }) as unknown as BillingClient);
|
||||
});
|
||||
|
||||
it('creates a customer portal session and prints the URL as JSON', async () => {
|
||||
createCustomerPortalSessionAsync.mockResolvedValue({
|
||||
url: 'https://billing.stripe.com/session/abc',
|
||||
});
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('account-id');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
customerPortalUrl: 'https://billing.stripe.com/session/abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the customer portal in a browser when interactive, without re-encoding the URL', async () => {
|
||||
const url = 'https://billing.stripe.com/session/abc%2Fdef';
|
||||
createCustomerPortalSessionAsync.mockResolvedValue({ url });
|
||||
jest.mocked(open).mockResolvedValue({} as never);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(url);
|
||||
});
|
||||
|
||||
it('prints the customer portal URL without opening a browser with --no-open', async () => {
|
||||
createCustomerPortalSessionAsync.mockResolvedValue({
|
||||
url: 'https://billing.stripe.com/session/abc',
|
||||
});
|
||||
|
||||
await createCommand(['--no-open']).runAsync();
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only shows accounts with active paid plans', async () => {
|
||||
const freeAccount = billingAccount('free-account-id', 'free-account');
|
||||
const productionAccount = billingAccount('production-account-id', 'production-account');
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockImplementation(async (_client, accountId) =>
|
||||
accountId === freeAccount.id
|
||||
? null
|
||||
: {
|
||||
id: `subscription-${accountId}`,
|
||||
name: accountId === productionAccount.id ? 'Production' : 'Starter',
|
||||
planId: `price-${accountId}`,
|
||||
}
|
||||
);
|
||||
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[1].value);
|
||||
createCustomerPortalSessionAsync.mockResolvedValue({
|
||||
url: 'https://billing.stripe.com/session',
|
||||
});
|
||||
|
||||
await createCommand([], [account, freeAccount, productionAccount]).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith(
|
||||
'Select an account:',
|
||||
[
|
||||
{
|
||||
title: 'testaccount',
|
||||
value: {
|
||||
id: 'account-id',
|
||||
name: 'testaccount',
|
||||
subscription: {
|
||||
id: 'subscription-account-id',
|
||||
name: 'Starter',
|
||||
planId: 'price-account-id',
|
||||
},
|
||||
},
|
||||
description: 'Current plan: Starter',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'production-account',
|
||||
value: {
|
||||
id: 'production-account-id',
|
||||
name: 'production-account',
|
||||
subscription: {
|
||||
id: 'subscription-production-account-id',
|
||||
name: 'Production',
|
||||
planId: 'price-production-account-id',
|
||||
},
|
||||
},
|
||||
description: 'Current plan: Production',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(createCustomerPortalSessionAsync).toHaveBeenCalledWith('production-account-id');
|
||||
});
|
||||
|
||||
it('rejects an account without an active paid plan', async () => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
|
||||
|
||||
await expect(createCommand(['--account', 'testaccount']).runAsync()).rejects.toThrow(
|
||||
'Account "testaccount" does not have an active paid plan. Run eas billing:subscribe to subscribe.'
|
||||
);
|
||||
|
||||
expect(createCustomerPortalSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import open from 'open';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { BillingClient } from '../../../billing/billingClient';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Role } from '../../../graphql/generated';
|
||||
import { AccountQuery } from '../../../graphql/queries/AccountQuery';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../../utils/json';
|
||||
import BillingSubscribe from '../subscribe';
|
||||
|
||||
jest.mock('open');
|
||||
jest.mock('../../../billing/billingClient');
|
||||
jest.mock('../../../graphql/queries/AccountQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../ora');
|
||||
|
||||
function billingAccount(
|
||||
id: string,
|
||||
name: string
|
||||
): { id: string; name: string; viewerUserPermission: { role: Role } } {
|
||||
return { id, name, viewerUserPermission: { role: Role.Admin } };
|
||||
}
|
||||
|
||||
const STARTER_SUBSCRIPTION = { id: 'sub_1', name: 'Starter', planId: 'price_paid' };
|
||||
const FREE_SUBSCRIPTION = { id: 'sub_free', name: 'Free', planId: 'price_free' };
|
||||
|
||||
describe(BillingSubscribe, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const account = billingAccount('account-id', 'testaccount');
|
||||
|
||||
const createCheckoutSessionAsync = jest.fn();
|
||||
const logEvent = jest.fn();
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
accounts: ReturnType<typeof billingAccount>[] = [account]
|
||||
): BillingSubscribe {
|
||||
const command = new BillingSubscribe(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
|
||||
analytics: { logEvent },
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
actor: { id: 'actor-id', accounts },
|
||||
authenticationInfo: { accessToken: 'token', sessionSecret: null },
|
||||
},
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(null);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
jest
|
||||
.mocked(BillingClient)
|
||||
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
|
||||
});
|
||||
|
||||
it('creates a checkout session for a plan and prints the URL as JSON', async () => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({
|
||||
id: 'cs_123',
|
||||
url: 'https://checkout.stripe.com/c/pay/cs_123',
|
||||
});
|
||||
|
||||
await createCommand(['starter', '--json']).runAsync();
|
||||
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_123',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps each plan slug to its server PlanType', async () => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(['production', '--json']).runAsync();
|
||||
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
|
||||
});
|
||||
|
||||
it('prompts for a plan when one is not provided', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValue('production');
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith('Select a plan:', [
|
||||
{ title: 'Starter', value: 'starter' },
|
||||
{ title: 'Production', value: 'production' },
|
||||
]);
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'PRODUCTION');
|
||||
expect(jest.mocked(AccountQuery.getSubscriptionAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(selectAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for an account before prompting for a plan', async () => {
|
||||
jest
|
||||
.mocked(selectAsync)
|
||||
.mockImplementationOnce(async (_message, choices) => choices[0].value)
|
||||
.mockResolvedValueOnce('production');
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(
|
||||
[],
|
||||
[account, billingAccount('second-account-id', 'second-account')]
|
||||
).runAsync();
|
||||
|
||||
expect(jest.mocked(selectAsync).mock.calls.map(([message]) => message)).toEqual([
|
||||
'Select an account:',
|
||||
'Select a plan:',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a plan in non-interactive mode', async () => {
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
'The plan argument is required in non-interactive mode.'
|
||||
);
|
||||
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows current plans and disables subscribed accounts in the account prompt', async () => {
|
||||
const subscribedAccount = billingAccount('subscribed-id', 'subscribed');
|
||||
const freeAccount = billingAccount('free-id', 'free');
|
||||
const secondFreeAccount = billingAccount('second-free-id', 'second-free');
|
||||
jest
|
||||
.mocked(AccountQuery.getSubscriptionAsync)
|
||||
.mockImplementation(async (_client, accountId) =>
|
||||
accountId === subscribedAccount.id ? STARTER_SUBSCRIPTION : null
|
||||
);
|
||||
jest.mocked(selectAsync).mockImplementation(async (_message, choices) => choices[0].value);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(
|
||||
['starter'],
|
||||
[subscribedAccount, freeAccount, secondFreeAccount]
|
||||
).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith(
|
||||
'Select an account:',
|
||||
[
|
||||
{
|
||||
title: 'free',
|
||||
value: { id: 'free-id', name: 'free', subscription: null },
|
||||
description: 'Current plan: Free',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'second-free',
|
||||
value: { id: 'second-free-id', name: 'second-free', subscription: null },
|
||||
description: 'Current plan: Free',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'subscribed',
|
||||
value: {
|
||||
id: 'subscribed-id',
|
||||
name: 'subscribed',
|
||||
subscription: STARTER_SUBSCRIPTION,
|
||||
},
|
||||
description: 'Current plan: Starter',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
initial: { id: 'free-id', name: 'free', subscription: null },
|
||||
warningMessageForDisabledEntries:
|
||||
'This account already has a paid plan. Run eas billing:manage to change it.',
|
||||
}
|
||||
);
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('free-id', 'STARTER');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicitly selected account is', ['production', '--account', 'testaccount', '--json']],
|
||||
['the only account is', ['production', '--json']],
|
||||
])('returns the current plan when %s already subscribed', async (_case, argv) => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
|
||||
|
||||
await createCommand(argv).runAsync();
|
||||
|
||||
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
alreadySubscribed: true,
|
||||
currentPlan: 'Starter',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an explicitly selected subscribed account before prompting for a plan', async () => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
|
||||
|
||||
await createCommand(['--account', 'testaccount']).runAsync();
|
||||
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(createCheckoutSessionAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Account testaccount is already subscribed to the Starter plan.'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith('To change or cancel your plan, run eas billing:manage.');
|
||||
});
|
||||
|
||||
it('treats the free plan as not subscribed', async () => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(FREE_SUBSCRIPTION);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(['starter', '--json']).runAsync();
|
||||
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--no-open']])(
|
||||
'prints the checkout URL without opening a browser with %s',
|
||||
async flag => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({
|
||||
id: 'cs',
|
||||
url: 'https://checkout.stripe.com/pay',
|
||||
});
|
||||
|
||||
await createCommand(['starter', flag]).runAsync();
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(link).toHaveBeenCalledWith('https://checkout.stripe.com/pay');
|
||||
}
|
||||
);
|
||||
|
||||
it('returns structured JSON with the checkout URL and does not open a browser', async () => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({
|
||||
id: 'cs',
|
||||
url: 'https://checkout.stripe.com/pay',
|
||||
});
|
||||
|
||||
await createCommand(['starter', '--json']).runAsync();
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
checkoutUrl: 'https://checkout.stripe.com/pay',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['checkout', null],
|
||||
['already subscribed', STARTER_SUBSCRIPTION],
|
||||
])(
|
||||
'does not emit null JSON values, which printJsonOnlyOutput strips (%s)',
|
||||
async (_case, subscription) => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(subscription);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(['starter', '--json']).runAsync();
|
||||
|
||||
const [payload] = jest.mocked(printJsonOnlyOutput).mock.calls[0];
|
||||
expect(Object.values(payload)).not.toContain(null);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['--json', true],
|
||||
['--no-open', false],
|
||||
])('reports the subscribe command to analytics (%s)', async (flag, json) => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: 'https://checkout' });
|
||||
|
||||
await createCommand(['production', flag]).runAsync();
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
|
||||
account_id: 'account-id',
|
||||
json,
|
||||
non_interactive: json,
|
||||
already_subscribed: false,
|
||||
plan: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an idempotent subscribe to analytics without a plan', async () => {
|
||||
jest.mocked(AccountQuery.getSubscriptionAsync).mockResolvedValue(STARTER_SUBSCRIPTION);
|
||||
|
||||
await createCommand(['production', '--json']).runAsync();
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('billing cli subscribe command', {
|
||||
account_id: 'account-id',
|
||||
json: true,
|
||||
non_interactive: true,
|
||||
already_subscribed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the checkout session has no URL', async () => {
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs', url: null });
|
||||
|
||||
await expect(createCommand(['starter', '--json']).runAsync()).rejects.toThrow(
|
||||
'The checkout session did not include a URL.'
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { BillingClient } from '../../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../../billing/openUrl';
|
||||
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ora } from '../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class BillingManage extends EasCommand {
|
||||
static override description = 'manage billing for an account with an active paid EAS plan';
|
||||
|
||||
static override flags = {
|
||||
account: Flags.string({
|
||||
char: 'a',
|
||||
description:
|
||||
'Account with an active paid plan to manage. Defaults to your account when only one is eligible.',
|
||||
}),
|
||||
'no-open': Flags.boolean({
|
||||
description: 'Only print the customer portal URL instead of opening it in a browser',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(BillingManage);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient, actor, authenticationInfo },
|
||||
} = await this.getContextAsync(BillingManage, { nonInteractive });
|
||||
|
||||
const account = await resolveBillingAccountAsync({
|
||||
graphqlClient,
|
||||
actor,
|
||||
accountName: flags.account,
|
||||
nonInteractive,
|
||||
subscriptionFilter: 'subscribed',
|
||||
});
|
||||
|
||||
const billingClient = new BillingClient(authenticationInfo);
|
||||
|
||||
const spinner = ora(`Creating a customer portal session for ${account.name}`).start();
|
||||
let portalUrl: string;
|
||||
try {
|
||||
const { url } = await billingClient.createCustomerPortalSessionAsync(account.id);
|
||||
portalUrl = url;
|
||||
spinner.succeed(`Created a customer portal session for ${account.name}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create a customer portal session');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ customerPortalUrl: portalUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
await openOrPrintUrlAsync(portalUrl, {
|
||||
label: 'Customer portal',
|
||||
open: !flags['no-open'] && !nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import { BillingEvent } from '../../analytics/AnalyticsManager';
|
||||
import { BillingClient } from '../../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../../billing/openUrl';
|
||||
import { PLAN_SLUGS, PlanSlug, SUBSCRIBABLE_PLANS, hasPaidSubscription } from '../../billing/plans';
|
||||
import { resolveBillingAccountAsync } from '../../billing/resolveAccount';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class BillingSubscribe extends EasCommand {
|
||||
static override description = 'subscribe a Free account to an EAS plan';
|
||||
|
||||
static override args = {
|
||||
PLAN: Args.string({
|
||||
description: 'plan to subscribe to. Required in non-interactive mode.',
|
||||
required: false,
|
||||
options: [...PLAN_SLUGS],
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
account: Flags.string({
|
||||
char: 'a',
|
||||
description: 'Account to subscribe. Defaults to your account when you only have one.',
|
||||
}),
|
||||
'no-open': Flags.boolean({
|
||||
description: 'Only print the checkout page URL instead of opening it in a browser',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { PLAN },
|
||||
flags,
|
||||
} = await this.parse(BillingSubscribe);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let planSlug = PLAN as PlanSlug | undefined;
|
||||
|
||||
const {
|
||||
analytics,
|
||||
loggedIn: { graphqlClient, actor, authenticationInfo },
|
||||
} = await this.getContextAsync(BillingSubscribe, { nonInteractive });
|
||||
|
||||
const account = await resolveBillingAccountAsync({
|
||||
graphqlClient,
|
||||
actor,
|
||||
accountName: flags.account,
|
||||
nonInteractive,
|
||||
subscriptionFilter: 'unsubscribed',
|
||||
});
|
||||
|
||||
const analyticsProperties = {
|
||||
account_id: account.id,
|
||||
json,
|
||||
non_interactive: nonInteractive,
|
||||
};
|
||||
|
||||
const { subscription } = account;
|
||||
|
||||
if (hasPaidSubscription(subscription)) {
|
||||
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
|
||||
...analyticsProperties,
|
||||
already_subscribed: true,
|
||||
});
|
||||
if (json) {
|
||||
printJsonOnlyOutput({
|
||||
alreadySubscribed: true,
|
||||
currentPlan: subscription?.name ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Log.warn(
|
||||
`Account ${account.name} is already subscribed${
|
||||
subscription?.name ? ` to the ${subscription.name} plan` : ''
|
||||
}.`
|
||||
);
|
||||
Log.log('To change or cancel your plan, run eas billing:manage.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!planSlug && nonInteractive) {
|
||||
throw new Error('The plan argument is required in non-interactive mode.');
|
||||
}
|
||||
|
||||
if (!planSlug) {
|
||||
planSlug = await selectAsync(
|
||||
'Select a plan:',
|
||||
PLAN_SLUGS.map(slug => ({
|
||||
title: SUBSCRIBABLE_PLANS[slug].label,
|
||||
value: slug,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const plan = SUBSCRIBABLE_PLANS[planSlug];
|
||||
|
||||
analytics.logEvent(BillingEvent.SUBSCRIBE_COMMAND, {
|
||||
...analyticsProperties,
|
||||
already_subscribed: false,
|
||||
plan: planSlug,
|
||||
});
|
||||
|
||||
const billingClient = new BillingClient(authenticationInfo);
|
||||
|
||||
const spinner = ora(`Creating a checkout session for the ${plan.label} plan`).start();
|
||||
let checkoutUrl: string;
|
||||
try {
|
||||
const session = await billingClient.createCheckoutSessionAsync(account.id, plan.planType);
|
||||
if (!session.url) {
|
||||
throw new Error('The checkout session did not include a URL.');
|
||||
}
|
||||
checkoutUrl = session.url;
|
||||
spinner.succeed(`Created a checkout session for the ${plan.label} plan`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create a checkout session');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ checkoutUrl, alreadySubscribed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(
|
||||
`Complete your subscription to the ${plan.label} plan for ${account.name} in Stripe checkout.`
|
||||
);
|
||||
await openOrPrintUrlAsync(checkoutUrl, {
|
||||
label: 'Checkout page',
|
||||
open: !flags['no-open'] && !nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../../simulator/expoGo';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -80,9 +80,7 @@ const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQue
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockResolveExpoGoApplicationArchiveUrlAsync = jest.mocked(
|
||||
resolveExpoGoApplicationArchiveUrlAsync
|
||||
);
|
||||
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
|
||||
const mockOra = jest.mocked(ora);
|
||||
const mockPromptAsync = jest.mocked(promptAsync);
|
||||
|
||||
@@ -166,9 +164,7 @@ describe(Simulator, () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(
|
||||
'https://example.test/expo-go.tar.gz'
|
||||
);
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
@@ -400,6 +396,26 @@ describe(Simulator, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --max-idle-time-minutes to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--max-idle-time-minutes',
|
||||
'30',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
maxIdleTimeMinutes: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
@@ -541,12 +557,11 @@ describe(Simulator, () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ios', AppPlatform.Ios, 'https://example.test/expo-go.tar.gz'],
|
||||
['android', AppPlatform.Android, 'https://example.test/expo-go.apk'],
|
||||
['ios', AppPlatform.Ios],
|
||||
['android', AppPlatform.Android],
|
||||
] as const)(
|
||||
'resolves and forwards the %s Expo Go archive when --expo-go is passed',
|
||||
async (platformFlag, appPlatform, applicationArchiveUrl) => {
|
||||
mockResolveExpoGoApplicationArchiveUrlAsync.mockResolvedValue(applicationArchiveUrl);
|
||||
'forwards Expo Go and the project SDK for %s when --expo-go is passed',
|
||||
async (platformFlag, appPlatform) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
platformFlag,
|
||||
@@ -556,20 +571,108 @@ describe(Simulator, () => {
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResolveExpoGoApplicationArchiveUrlAsync).toHaveBeenCalledWith({
|
||||
platform: platformFlag,
|
||||
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
|
||||
projectDir,
|
||||
sdkVersion: undefined,
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl,
|
||||
expoGo: true,
|
||||
sdkVersion: '55.0.0',
|
||||
platform: appPlatform,
|
||||
})
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ applicationArchiveUrl: expect.anything() })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses --sdk-version to select Expo Go', async () => {
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValueOnce('57.0.0');
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--expo-go',
|
||||
'--sdk-version',
|
||||
'57.0.0',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResolveExpoGoSdkVersionAsync).toHaveBeenCalledWith({
|
||||
projectDir,
|
||||
sdkVersion: '57.0.0',
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ expoGo: true, sdkVersion: '57.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards repeated launch arguments and a URL to open', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-id',
|
||||
'8d8b713c-1834-4bd3-91e6-46f895422cbc',
|
||||
'--launch-arg',
|
||||
'--uitesting',
|
||||
'--launch-arg',
|
||||
'true',
|
||||
'--open-url',
|
||||
' exp://example.test ',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
launchArgs: ['--uitesting', 'true'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--launch-arg', '--uitesting'],
|
||||
['--open-url', 'exp://example.test'],
|
||||
])('rejects %s without an application source', async (launchFlag, launchValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
launchFlag,
|
||||
launchValue,
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Launch options require an application source.'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --sdk-version without --expo-go', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--sdk-version',
|
||||
'57',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'The --sdk-version flag can only be used with --expo-go.'
|
||||
);
|
||||
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects passing --build-id and --application-archive-url together', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -599,7 +702,7 @@ describe(Simulator, () => {
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockResolveExpoGoApplicationArchiveUrlAsync).not.toHaveBeenCalled();
|
||||
expect(mockResolveExpoGoSdkVersionAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { resolveExpoGoApplicationArchiveUrlAsync } from '../../simulator/expoGo';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
@@ -87,6 +87,19 @@ export default class Simulator extends EasCommand {
|
||||
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
|
||||
exclusive: ['build-id', 'application-archive-url'],
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description:
|
||||
'Expo SDK version used to select Expo Go when --expo-go is passed. Defaults to the current project SDK.',
|
||||
}),
|
||||
'launch-arg': Flags.string({
|
||||
description:
|
||||
'Argument passed to the installed application when it launches. Repeat for multiple arguments.',
|
||||
multiple: true,
|
||||
}),
|
||||
'open-url': Flags.string({
|
||||
description:
|
||||
'Expo or development-client URL to open in the installed application after it launches.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
@@ -101,6 +114,11 @@ export default class Simulator extends EasCommand {
|
||||
'Maximum duration of the simulator session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
|
||||
min: 0,
|
||||
}),
|
||||
'max-idle-time-minutes': Flags.integer({
|
||||
description:
|
||||
'Stop the simulator session automatically after this many minutes without session activity. When omitted, the session has no idle timeout and runs until its maximum duration.',
|
||||
min: 0,
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
@@ -156,6 +174,23 @@ export default class Simulator extends EasCommand {
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
|
||||
const launchArgs = flags['launch-arg'];
|
||||
const openUrl = flags['open-url']?.trim() || undefined;
|
||||
|
||||
if (sdkVersionFromFlag && !flags['expo-go']) {
|
||||
throw new EasCommandError('The --sdk-version flag can only be used with --expo-go.');
|
||||
}
|
||||
if (
|
||||
(launchArgs?.length || openUrl) &&
|
||||
!buildId &&
|
||||
!applicationArchiveUrlFromFlag &&
|
||||
!flags['expo-go']
|
||||
) {
|
||||
throw new EasCommandError(
|
||||
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
|
||||
);
|
||||
}
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
@@ -166,12 +201,9 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const applicationArchiveUrl = flags['expo-go']
|
||||
? await resolveExpoGoApplicationArchiveUrlAsync({
|
||||
platform: platform === AppPlatform.Ios ? 'ios' : 'android',
|
||||
projectDir,
|
||||
})
|
||||
: applicationArchiveUrlFromFlag;
|
||||
const expoGoSdkVersion = flags['expo-go']
|
||||
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
|
||||
: undefined;
|
||||
|
||||
if (existingDeviceRunSessionId) {
|
||||
Log.warn(
|
||||
@@ -195,8 +227,14 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
deviceIdentifier,
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(applicationArchiveUrl ? { applicationArchiveUrl } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
|
||||
: {}),
|
||||
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
|
||||
...(launchArgs?.length ? { launchArgs } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
nullthrows(session.turtleJobRun?.id, 'Expected simulator session to start');
|
||||
|
||||
+31
-3
@@ -5236,12 +5236,12 @@ export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
* Application archive URL to download, install, and launch before the simulator session
|
||||
* becomes available. Mutually exclusive with buildId.
|
||||
* becomes available. Mutually exclusive with buildId and expoGo.
|
||||
*/
|
||||
applicationArchiveUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* EAS Build to install and launch before the simulator session becomes available.
|
||||
* Mutually exclusive with applicationArchiveUrl.
|
||||
* Mutually exclusive with applicationArchiveUrl and expoGo.
|
||||
*/
|
||||
buildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
/**
|
||||
@@ -5251,6 +5251,17 @@ export type CreateDeviceRunSessionInput = {
|
||||
* default device.
|
||||
*/
|
||||
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* Install and launch Expo Go before the simulator session becomes available. The server resolves
|
||||
* the platform-specific application archive. Mutually exclusive with buildId and
|
||||
* applicationArchiveUrl.
|
||||
*/
|
||||
expoGo?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/**
|
||||
* Arguments passed to the installed application when it is launched. Requires buildId,
|
||||
* applicationArchiveUrl, or expoGo.
|
||||
*/
|
||||
launchArgs?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/**
|
||||
* Stop the session automatically after this many minutes without observed
|
||||
* session activity. Must be positive and smaller than the session's maximum
|
||||
@@ -5271,12 +5282,22 @@ export type CreateDeviceRunSessionInput = {
|
||||
* session is unnamed and clients fall back to identifying it by id.
|
||||
*/
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* Expo or development-client URL to open after launching the installed application. Requires
|
||||
* buildId, applicationArchiveUrl, or expoGo.
|
||||
*/
|
||||
openUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
/**
|
||||
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
|
||||
* If omitted, consumers treat the session as pinned to "latest".
|
||||
*/
|
||||
packageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppPlatform;
|
||||
/**
|
||||
* Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is
|
||||
* true. If omitted, the current Expo Go archive for the platform is used.
|
||||
*/
|
||||
sdkVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
type: DeviceRunSessionType;
|
||||
};
|
||||
|
||||
@@ -14657,7 +14678,7 @@ export type AccountByNameQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string } } };
|
||||
export type AccountByNameQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byName: { __typename?: 'Account', id: string, name: string, viewerUserPermission: { __typename?: 'UserPermission', id: string, permissions: Array<Permission> } } } };
|
||||
|
||||
export type AccountFullUsageQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
@@ -14685,6 +14706,13 @@ export type AccountBillingPeriodQueryVariables = Exact<{
|
||||
|
||||
export type AccountBillingPeriodQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any } } } };
|
||||
|
||||
export type AccountSubscriptionQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountSubscriptionQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, planId?: string | null } | null } } };
|
||||
|
||||
export type AppByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
@@ -4,8 +4,12 @@ import gql from 'graphql-tag';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountByNameQuery as AccountByNameQueryType,
|
||||
AccountByNameQueryVariables,
|
||||
AccountFullUsageQuery as AccountFullUsageQueryType,
|
||||
AccountFullUsageQueryVariables,
|
||||
AccountSubscriptionQuery as AccountSubscriptionQueryType,
|
||||
AccountSubscriptionQueryVariables,
|
||||
AccountUsageForOverageWarningQuery,
|
||||
AccountUsageForOverageWarningQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -17,21 +21,28 @@ import {
|
||||
} from '../types/Account';
|
||||
|
||||
export type AccountFullUsageData = NonNullable<AccountFullUsageQueryType['account']['byId']>;
|
||||
export type AccountSubscriptionInfo = NonNullable<
|
||||
NonNullable<AccountSubscriptionQueryType['account']['byId']>['subscription']
|
||||
>;
|
||||
|
||||
export const AccountQuery = {
|
||||
async getByNameAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountName: string
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
): Promise<AccountByNameQueryType['account']['byName'] | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<{ account: { byName: { id: string; name: string } | null } }>(
|
||||
.query<AccountByNameQueryType, AccountByNameQueryVariables>(
|
||||
gql`
|
||||
query AccountByName($accountName: String!) {
|
||||
account {
|
||||
byName(accountName: $accountName) {
|
||||
id
|
||||
name
|
||||
viewerUserPermission {
|
||||
id
|
||||
permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,4 +287,34 @@ export const AccountQuery = {
|
||||
end: new Date(end),
|
||||
};
|
||||
},
|
||||
|
||||
async getSubscriptionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
): Promise<AccountSubscriptionInfo | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountSubscriptionQueryType, AccountSubscriptionQueryVariables>(
|
||||
gql`
|
||||
query AccountSubscription($accountId: String!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
planId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId },
|
||||
{ additionalTypenames: ['Account', 'SubscriptionDetails'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId?.subscription ?? null;
|
||||
},
|
||||
};
|
||||
@@ -1,65 +1,40 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
|
||||
import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVersionAsync';
|
||||
import { resolveExpoGoApplicationArchiveUrlAsync } from '../expoGo';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../expoGo';
|
||||
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('../../project/detectProjectSdkVersionAsync');
|
||||
|
||||
const projectDir = '/test/project';
|
||||
const mockDetectProjectSdkVersionAsync = jest.mocked(detectProjectSdkVersionAsync);
|
||||
const mockSpawnAsync = jest.mocked(spawnAsync);
|
||||
|
||||
describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
|
||||
describe(resolveExpoGoSdkVersionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDetectProjectSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
mockSpawnAsync.mockResolvedValue({
|
||||
stdout: 'https://example.test/expo-go.tar.gz\n',
|
||||
} as never);
|
||||
});
|
||||
|
||||
it.each(['ios', 'android'] as const)(
|
||||
'resolves the Expo Go URL for SDK 55 on %s',
|
||||
async platform => {
|
||||
await expect(resolveExpoGoApplicationArchiveUrlAsync({ platform, projectDir })).resolves.toBe(
|
||||
'https://example.test/expo-go.tar.gz'
|
||||
);
|
||||
|
||||
expect(mockSpawnAsync).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
['--yes', 'expo-go', 'url', platform, '55'],
|
||||
{
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
it('normalizes the detected project SDK version', async () => {
|
||||
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).resolves.toBe('55.0.0');
|
||||
expect(mockDetectProjectSdkVersionAsync).toHaveBeenCalledWith(projectDir);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'plain output',
|
||||
'Resolving the correct Expo Go version...\n' +
|
||||
'Download Expo Go from https://example.test/Expo-Go-57.0.9.tar.gz\n',
|
||||
],
|
||||
[
|
||||
'Markdown hyperlink output',
|
||||
'Resolving the correct Expo Go version...\n' +
|
||||
'Download Expo Go from [https://example.test/Expo-Go-57.0.9.tar.gz](https://example.test/Expo-Go-57.0.9.tar.gz)\n',
|
||||
],
|
||||
[
|
||||
'terminal hyperlink output',
|
||||
'Resolving the correct Expo Go version...\n' +
|
||||
'Download Expo Go from \u001B]8;;https://example.test/Expo-Go-57.0.9.tar.gz\u0007' +
|
||||
'https://example.test/Expo-Go-57.0.9.tar.gz\u001B]8;;\u0007\n',
|
||||
],
|
||||
])('extracts the URL from %s', async (_description, stdout) => {
|
||||
mockSpawnAsync.mockResolvedValue({ stdout } as never);
|
||||
['57', '57.0.0'],
|
||||
['57.0.0', '57.0.0'],
|
||||
['57.0.9', '57.0.0'],
|
||||
])('normalizes an explicit SDK version (%s)', async (sdkVersion, expectedSdkVersion) => {
|
||||
await expect(resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion })).resolves.toBe(
|
||||
expectedSdkVersion
|
||||
);
|
||||
expect(mockDetectProjectSdkVersionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid explicit SDK version', async () => {
|
||||
await expect(
|
||||
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
|
||||
).resolves.toBe('https://example.test/Expo-Go-57.0.9.tar.gz');
|
||||
resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: 'not-a-version' })
|
||||
).rejects.toThrow(
|
||||
'Unable to parse Expo SDK version "not-a-version". Pass a major or semantic version, such as --sdk-version 57.'
|
||||
);
|
||||
expect(mockDetectProjectSdkVersionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, 'UNVERSIONED'])(
|
||||
@@ -67,36 +42,9 @@ describe(resolveExpoGoApplicationArchiveUrlAsync, () => {
|
||||
async sdkVersion => {
|
||||
mockDetectProjectSdkVersionAsync.mockResolvedValue(sdkVersion);
|
||||
|
||||
await expect(
|
||||
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
|
||||
).rejects.toThrow(
|
||||
await expect(resolveExpoGoSdkVersionAsync({ projectDir })).rejects.toThrow(
|
||||
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected."
|
||||
);
|
||||
expect(mockSpawnAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('reports how to diagnose an expo-go failure', async () => {
|
||||
mockSpawnAsync.mockRejectedValue(new Error('network unavailable') as never);
|
||||
|
||||
await expect(
|
||||
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'android', projectDir })
|
||||
).rejects.toThrow(
|
||||
'Failed to resolve the Expo Go download URL for SDK 55 on android. network unavailable ' +
|
||||
'Run "npx expo-go url android 55" to diagnose the problem'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'not a URL',
|
||||
'file:///tmp/expo-go.app',
|
||||
'Resolving the correct Expo Go version...\nDownload Expo Go from nowhere\n',
|
||||
])('rejects an invalid expo-go URL (%s)', async stdout => {
|
||||
mockSpawnAsync.mockResolvedValue({ stdout } as never);
|
||||
|
||||
await expect(
|
||||
resolveExpoGoApplicationArchiveUrlAsync({ platform: 'ios', projectDir })
|
||||
).rejects.toThrow('expo-go returned an invalid download URL for SDK 55 on ios.');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,28 @@
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import semver from 'semver';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersionAsync';
|
||||
|
||||
export type ExpoGoPlatform = 'android' | 'ios';
|
||||
|
||||
const EXPO_GO_DOWNLOAD_MESSAGE = 'Download Expo Go from';
|
||||
|
||||
function extractApplicationArchiveUrl(stdout: string): string | undefined {
|
||||
const output = stripVTControlCharacters(stdout);
|
||||
const outputLines = output.trim().split(/\r?\n/);
|
||||
const downloadMessageLine = outputLines.find(line => line.includes(EXPO_GO_DOWNLOAD_MESSAGE));
|
||||
const bareUrlOutput = outputLines.length === 1 ? outputLines[0] : undefined;
|
||||
const lineWithUrl = downloadMessageLine ?? bareUrlOutput;
|
||||
|
||||
return lineWithUrl?.match(/https?:\/\/[^\s\])]+/)?.[0];
|
||||
}
|
||||
|
||||
export async function resolveExpoGoApplicationArchiveUrlAsync({
|
||||
platform,
|
||||
export async function resolveExpoGoSdkVersionAsync({
|
||||
projectDir,
|
||||
sdkVersion: sdkVersionFromFlag,
|
||||
}: {
|
||||
platform: ExpoGoPlatform;
|
||||
projectDir: string;
|
||||
sdkVersion?: string;
|
||||
}): Promise<string> {
|
||||
const sdkVersion = await detectProjectSdkVersionAsync(projectDir);
|
||||
const sdkVersion = sdkVersionFromFlag ?? (await detectProjectSdkVersionAsync(projectDir));
|
||||
const sdkMajorVersion = semver.coerce(sdkVersion)?.major;
|
||||
if (!sdkMajorVersion) {
|
||||
if (sdkVersionFromFlag) {
|
||||
throw new EasCommandError(
|
||||
`Unable to parse Expo SDK version "${sdkVersionFromFlag}". Pass a major or semantic version, such as --sdk-version 57.`
|
||||
);
|
||||
}
|
||||
throw new EasCommandError(
|
||||
"Unable to determine this project's Expo SDK version, so Expo Go could not be selected. " +
|
||||
'Make sure the project has Expo installed and a valid app config, or use --build-id or --application-archive-url instead.'
|
||||
);
|
||||
}
|
||||
|
||||
const args = ['--yes', 'expo-go', 'url', platform, String(sdkMajorVersion)];
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawnAsync('npx', args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
}));
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? ` ${error.message}` : '';
|
||||
throw new EasCommandError(
|
||||
`Failed to resolve the Expo Go download URL for SDK ${sdkMajorVersion} on ${platform}.${reason} ` +
|
||||
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
|
||||
);
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = extractApplicationArchiveUrl(stdout);
|
||||
try {
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new Error('Missing URL');
|
||||
}
|
||||
const parsedUrl = new URL(applicationArchiveUrl);
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('Unsupported protocol');
|
||||
}
|
||||
} catch {
|
||||
throw new EasCommandError(
|
||||
`expo-go returned an invalid download URL for SDK ${sdkMajorVersion} on ${platform}. ` +
|
||||
`Run "npx expo-go url ${platform} ${sdkMajorVersion}" to diagnose the problem, or use --build-id or --application-archive-url instead.`
|
||||
);
|
||||
}
|
||||
|
||||
return applicationArchiveUrl;
|
||||
return `${sdkMajorVersion}.0.0`;
|
||||
}
|
||||
@@ -462,6 +462,11 @@ describe('displayOverageWarning', () => {
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Upgrade your plan to continue service.')
|
||||
);
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
)
|
||||
);
|
||||
expect(mockWarn).toHaveBeenCalledWith(expect.stringMatching(/█+░+/));
|
||||
});
|
||||
|
||||
@@ -505,6 +510,11 @@ describe('displayOverageWarning', () => {
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('New builds are blocked until your billing period resets.')
|
||||
);
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('at tier: displays a paid-plan limit-reached message with pay-as-you-go warning', () => {
|
||||
@@ -543,6 +553,11 @@ describe('displayOverageWarning', () => {
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('New builds are blocked until your billing period resets.')
|
||||
);
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('over tier: displays a paid-plan overage count and accrued cost', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { formatStarterSubscribeCommand } from '../../billing/plans';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import Log, { link } from '../../log';
|
||||
@@ -136,6 +137,7 @@ export function displayOverageWarning({
|
||||
overageCostCents: number;
|
||||
}): void {
|
||||
const billingUrl = `https://expo.dev/accounts/${name}/settings/billing`;
|
||||
const upgradeHint = ` Run ${formatStarterSubscribeCommand(name)} to upgrade to the Starter plan.`;
|
||||
|
||||
if (tier === 'approaching') {
|
||||
const percentUsed = calculatePercentUsed(planValue, limit);
|
||||
@@ -149,7 +151,8 @@ export function displayOverageWarning({
|
||||
Log.warn(
|
||||
hasFreePlan
|
||||
? "You won't be able to start new builds once you reach the limit. " +
|
||||
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false })
|
||||
link(billingUrl, { text: 'Upgrade your plan to continue service.', dim: false }) +
|
||||
upgradeHint
|
||||
: 'Additional usage beyond your limit will be charged at pay-as-you-go rates. ' +
|
||||
link(billingUrl, { text: 'See usage in billing.', dim: false })
|
||||
);
|
||||
@@ -163,7 +166,8 @@ export function displayOverageWarning({
|
||||
Log.warn(
|
||||
hasFreePlan
|
||||
? 'New builds are blocked until your billing period resets. ' +
|
||||
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false })
|
||||
link(billingUrl, { text: 'Upgrade your plan to continue building.', dim: false }) +
|
||||
upgradeHint
|
||||
: 'Additional builds will be charged at pay-as-you-go rates. ' +
|
||||
link(billingUrl, { text: 'See usage in billing.', dim: false })
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "22.3.0",
|
||||
"version": "22.5.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,7 +26,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "22.3.0",
|
||||
"@expo/build-tools": "22.5.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "22.1.0",
|
||||
"version": "22.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { BuildStepOutputAccessor } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInput } from './BuildStepInput';
|
||||
import { BuildStepRuntimeError } from './errors';
|
||||
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from './errors';
|
||||
import {
|
||||
resolveInterpolatedTarget,
|
||||
stringifyInterpolatedResult,
|
||||
@@ -77,7 +77,20 @@ export class BuildStepCompositeFunctionScope {
|
||||
if (this.parent && !this.parent.isActive(evaluate, runByDefault)) {
|
||||
return false;
|
||||
}
|
||||
this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate, runByDefault);
|
||||
if (this.cachedIsActive === undefined) {
|
||||
try {
|
||||
this.cachedIsActive = this.evaluateCallIfCondition(evaluate, runByDefault);
|
||||
} catch (err) {
|
||||
// An unevaluable gate skips the call, cache the skip before throwing.
|
||||
// Only the first child reports the error, the rest skip silently.
|
||||
this.cachedIsActive = false;
|
||||
throw new BuildStepConditionEvaluationError(
|
||||
`composite function call "${this.compositeFunctionPath}"`,
|
||||
this.ifCondition ?? '',
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.cachedIsActive;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BuildStep } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { CompositeBuildStep } from './CompositeBuildStep';
|
||||
import { StepMetricResult } from './StepMetrics';
|
||||
import { BuildStepConditionEvaluationError } from './errors';
|
||||
import { AnchorHooks, HookEntry } from './hooks';
|
||||
import { evaluateIfCondition } from './utils/jsepEval';
|
||||
|
||||
@@ -279,15 +280,17 @@ export async function executeHookStepsAsync(
|
||||
return { failedLocally, firstError };
|
||||
}
|
||||
|
||||
// Shared wording for unevaluable `if:` gates. Callers pass the step's own
|
||||
// `if:`, but the throw may come from a composite call-site `if:` evaluated via
|
||||
// the step's scope. In that case the quoted condition is not the one that failed.
|
||||
// Shared wording for unevaluable `if:` gates.
|
||||
function logConditionEvaluationError(
|
||||
logger: bunyan,
|
||||
err: unknown,
|
||||
subject: string,
|
||||
ifCondition: string | undefined
|
||||
): void {
|
||||
if (err instanceof BuildStepConditionEvaluationError) {
|
||||
({ subject, ifCondition } = err);
|
||||
err = err.cause ?? err;
|
||||
}
|
||||
logger.error({ err });
|
||||
logger.error(
|
||||
`Runner failed to evaluate if it should execute ${subject}${
|
||||
|
||||
@@ -2,16 +2,26 @@ import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getError } from './utils/error';
|
||||
import { BuildStep } from '../BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { BuildStepConditionEvaluationError } from '../errors';
|
||||
import { interpolateJobContext } from '../interpolation';
|
||||
|
||||
describe(BuildStepCompositeFunctionScope, () => {
|
||||
const baseContext = {} as unknown as JobInterpolationContext;
|
||||
|
||||
function makeScope(): BuildStepCompositeFunctionScope {
|
||||
function makeScope({
|
||||
ifCondition,
|
||||
parent,
|
||||
compositeFunctionPath = 'test-action',
|
||||
}: {
|
||||
ifCondition?: string;
|
||||
parent?: BuildStepCompositeFunctionScope;
|
||||
compositeFunctionPath?: string;
|
||||
} = {}): BuildStepCompositeFunctionScope {
|
||||
const ctx = createGlobalContextMock();
|
||||
|
||||
const versionOutput = mock<BuildStepOutput>();
|
||||
@@ -29,7 +39,9 @@ describe(BuildStepCompositeFunctionScope, () => {
|
||||
greeting.set('hello');
|
||||
return new BuildStepCompositeFunctionScope({
|
||||
ctx,
|
||||
compositeFunctionPath: 'test-action',
|
||||
parent,
|
||||
ifCondition,
|
||||
compositeFunctionPath,
|
||||
inputs: new Map([['greeting', greeting]]),
|
||||
providedInputKeys: new Set(['greeting']),
|
||||
childrenByLocalId: new Map([['build', instance(innerStep)]]),
|
||||
@@ -69,5 +81,46 @@ describe(BuildStepCompositeFunctionScope, () => {
|
||||
expect(scope.isActive(neverEvaluate, true)).toBe(true);
|
||||
expect(scope.isActive(neverEvaluate, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps an evaluation failure with the call as subject, its condition, and the raw cause', () => {
|
||||
const rawError = new Error('boom');
|
||||
const scope = makeScope({ ifCondition: '${{ broken }}' });
|
||||
const error = getError(() =>
|
||||
scope.isActive(() => {
|
||||
throw rawError;
|
||||
}, true)
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
|
||||
expect(error.subject).toBe('composite function call "test-action"');
|
||||
expect(error.ifCondition).toBe('${{ broken }}');
|
||||
expect(error.cause).toBe(rawError);
|
||||
});
|
||||
|
||||
it('reports an evaluation failure once; later calls skip without re-evaluating', () => {
|
||||
let evaluations = 0;
|
||||
const evaluate = (): boolean => {
|
||||
evaluations += 1;
|
||||
throw new Error('boom');
|
||||
};
|
||||
const scope = makeScope({ ifCondition: '${{ broken }}' });
|
||||
expect(() => scope.isActive(evaluate, true)).toThrow(BuildStepConditionEvaluationError);
|
||||
expect(scope.isActive(evaluate, true)).toBe(false);
|
||||
expect(evaluations).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates a parent call-site failure naming the parent scope, not the child', () => {
|
||||
const parent = makeScope({
|
||||
ifCondition: '${{ broken }}',
|
||||
compositeFunctionPath: 'parent-action',
|
||||
});
|
||||
const child = makeScope({ parent });
|
||||
const error = getError(() =>
|
||||
child.isActive(() => {
|
||||
throw new Error('boom');
|
||||
}, true)
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
|
||||
expect(error.subject).toBe('composite function call "parent-action"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Hooks, Step } from '@expo/eas-build-job';
|
||||
|
||||
import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { createRecordingLogger } from './utils/logger';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildStepStatus } from '../BuildStep';
|
||||
@@ -13,17 +14,20 @@ import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { StepsConfigParser } from '../StepsConfigParser';
|
||||
import { WorkflowHookMetric } from '../StepMetrics';
|
||||
import { BuildStepRuntimeError } from '../errors';
|
||||
import { BuildStepConditionEvaluationError, BuildStepRuntimeError } from '../errors';
|
||||
|
||||
describe('BuildWorkflow hook execution', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
let executionLog: string[];
|
||||
let metrics: WorkflowHookMetric[];
|
||||
let loggedMessages: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
executionLog = [];
|
||||
metrics = [];
|
||||
loggedMessages = [];
|
||||
ctx = createGlobalContextMock({
|
||||
logger: createRecordingLogger(loggedMessages),
|
||||
reportWorkflowHookMetric: metric => metrics.push(metric),
|
||||
});
|
||||
await fs.mkdir(ctx.defaultWorkingDirectory, { recursive: true });
|
||||
@@ -650,6 +654,44 @@ describe('BuildWorkflow hook execution', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('attributes a broken composite call-site if: to the call, logs it once, and skips the anchor', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{
|
||||
uses: './.eas/functions/setup',
|
||||
id: 'setup',
|
||||
if: '${{ nonexistent.object.property }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
externalFunctions: [anchorFunction(), recordingFunction('one'), recordingFunction('two')],
|
||||
compositeFunctionCatalog: {
|
||||
'./.eas/functions/setup': {
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'first', uses: 'test/one' },
|
||||
{ id: 'second', uses: 'test/two', if: '${{ always() }}' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow(BuildStepConditionEvaluationError);
|
||||
expect(executionLog).toEqual([]);
|
||||
expect(hookStepStatuses(workflow)['setup__first']).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(hookStepStatuses(workflow)['setup__second']).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(metrics).toEqual([]);
|
||||
|
||||
const gateMessages = loggedMessages.filter(m => m.includes('Runner failed to evaluate'));
|
||||
expect(gateMessages).toHaveLength(1);
|
||||
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/setup"');
|
||||
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
|
||||
expect(loggedMessages.join('\n')).not.toContain('${{ always() }}');
|
||||
});
|
||||
|
||||
it('skips every step of a composite call whose if condition is false and reports no metric', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
|
||||
@@ -10,13 +10,18 @@ import {
|
||||
} from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { createRecordingLogger } from './utils/logger';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { StepsConfigParser } from '../StepsConfigParser';
|
||||
import { BuildStepStatus } from '../BuildStep';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
|
||||
import {
|
||||
BuildConfigError,
|
||||
BuildStepConditionEvaluationError,
|
||||
BuildStepRuntimeError,
|
||||
} from '../errors';
|
||||
|
||||
describe('StepsConfigParser local composite functions', () => {
|
||||
describe('step reference scoping', () => {
|
||||
@@ -1252,4 +1257,73 @@ describe('StepsConfigParser local composite functions', () => {
|
||||
expect(show?.getOutputValueByName('out')).toBe('hello');
|
||||
});
|
||||
});
|
||||
describe('call-site if evaluation errors', () => {
|
||||
it('attributes a broken call-site if: to the composite call and logs it once', async () => {
|
||||
const messages: string[] = [];
|
||||
const workflow = await parseCompositeFunctions({
|
||||
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'first', uses: 'eas/echo', if: '${{ always() }}' },
|
||||
{ id: 'second', uses: 'eas/echo' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', if: '${{ nonexistent.object.property }}' }],
|
||||
externalFunctions: [echoFunction()],
|
||||
});
|
||||
|
||||
const error = await getErrorAsync(() => workflow.executeAsync());
|
||||
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
|
||||
expect(error.ifCondition).toBe('${{ nonexistent.object.property }}');
|
||||
|
||||
for (const id of ['setup__first', 'setup__second']) {
|
||||
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
|
||||
}
|
||||
|
||||
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
|
||||
expect(gateMessages).toHaveLength(1);
|
||||
expect(gateMessages[0]).toContain(`composite function call "${SETUP}"`);
|
||||
expect(gateMessages[0]).toContain('${{ nonexistent.object.property }}');
|
||||
expect(messages.join('\n')).not.toContain('${{ always() }}');
|
||||
});
|
||||
|
||||
it('names the outer call when its broken if: gates a nested composite, still logging once', async () => {
|
||||
const messages: string[] = [];
|
||||
const workflow = await parseCompositeFunctions({
|
||||
ctx: createGlobalContextMock({ logger: createRecordingLogger(messages) }),
|
||||
catalog: {
|
||||
'./.eas/functions/outer': {
|
||||
runs: {
|
||||
steps: [
|
||||
{ uses: './.eas/functions/inner', id: 'nested' },
|
||||
{ id: 'tail', uses: 'eas/echo' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'./.eas/functions/inner': {
|
||||
runs: { steps: [{ id: 'leaf', uses: 'eas/echo' }] },
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{ uses: './.eas/functions/outer', id: 'top', if: '${{ nonexistent.object.property }}' },
|
||||
],
|
||||
externalFunctions: [echoFunction()],
|
||||
});
|
||||
|
||||
const error = await getErrorAsync(() => workflow.executeAsync());
|
||||
expect(error).toBeInstanceOf(BuildStepConditionEvaluationError);
|
||||
|
||||
for (const id of ['top__nested__leaf', 'top__tail']) {
|
||||
expect(workflow.buildSteps.find(s => s.id === id)?.status).toBe(BuildStepStatus.SKIPPED);
|
||||
}
|
||||
|
||||
const gateMessages = messages.filter(m => m.includes('Runner failed to evaluate'));
|
||||
expect(gateMessages).toHaveLength(1);
|
||||
expect(gateMessages[0]).toContain('composite function call "./.eas/functions/outer"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildStepGlobalContext } from '../BuildStepContext';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
@@ -23,8 +24,9 @@ export async function parseCompositeFunctions(options: {
|
||||
steps: Step[];
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
ctx?: BuildStepGlobalContext;
|
||||
}): Promise<BuildWorkflow> {
|
||||
const ctx = createGlobalContextMock();
|
||||
const ctx = options.ctx ?? createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
steps: options.steps,
|
||||
hooks: undefined,
|
||||
|
||||
@@ -11,3 +11,20 @@ export function createMockLogger(): bunyan {
|
||||
} as unknown as bunyan;
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function createRecordingLogger(sink: string[]): bunyan {
|
||||
const record = (...args: unknown[]): void => {
|
||||
const message = args.find(arg => typeof arg === 'string');
|
||||
if (typeof message === 'string') {
|
||||
sink.push(message);
|
||||
}
|
||||
};
|
||||
const logger = {
|
||||
info: jest.fn(record),
|
||||
debug: jest.fn(record),
|
||||
error: jest.fn(record),
|
||||
warn: jest.fn(record),
|
||||
child: jest.fn(() => logger),
|
||||
} as unknown as bunyan;
|
||||
return logger;
|
||||
}
|
||||
@@ -23,6 +23,18 @@ export class BuildInternalError extends Error {}
|
||||
|
||||
export class BuildStepRuntimeError extends UserError {}
|
||||
|
||||
export class BuildStepConditionEvaluationError extends UserError {
|
||||
constructor(
|
||||
public readonly subject: string,
|
||||
public readonly ifCondition: string,
|
||||
extra?: {
|
||||
cause?: Error;
|
||||
}
|
||||
) {
|
||||
super(`Failed to evaluate the if condition "${ifCondition}" of ${subject}.`, extra);
|
||||
}
|
||||
}
|
||||
|
||||
export class BuildWorkflowError extends UserError {
|
||||
constructor(
|
||||
public override readonly message: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "22.3.0",
|
||||
"version": "22.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "22.3.0",
|
||||
"@expo/build-tools": "22.5.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "22.0.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -2291,7 +2291,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:22.3.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:22.5.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -2306,7 +2306,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.1.0"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -2887,7 +2887,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:22.1.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:22.4.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
@@ -2962,7 +2962,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:22.3.0"
|
||||
"@expo/build-tools": "npm:22.5.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:22.0.0"
|
||||
@@ -2971,7 +2971,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.1.0"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -9727,7 +9727,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:22.1.0"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10300,7 +10300,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:22.3.0"
|
||||
"@expo/build-tools": "npm:22.5.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
@@ -10374,7 +10374,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.1.0"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
@@ -10451,6 +10451,7 @@ __metadata:
|
||||
node-forge: "npm:1.4.0"
|
||||
node-stream-zip: "npm:1.15.0"
|
||||
nullthrows: "npm:1.1.1"
|
||||
open: "npm:8.4.0"
|
||||
ora: "npm:5.1.0"
|
||||
pkg-dir: "npm:4.2.0"
|
||||
pngjs: "npm:7.0.0"
|
||||
@@ -15833,7 +15834,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"open@npm:^8.0.4, open@npm:^8.4.0":
|
||||
"open@npm:8.4.0, open@npm:^8.0.4, open@npm:^8.4.0":
|
||||
version: 8.4.0
|
||||
resolution: "open@npm:8.4.0"
|
||||
dependencies:
|
||||
|
||||
Reference in new issue
Block a user